mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 13:27:13 +00:00
Merge remote-tracking branch 'origin/main' into feat/lint-gsap-non-transform-motion
# Conflicts: # skills-manifest.json
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "hyperframes",
|
||||
"description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website-to-video capture for HyperFrames.",
|
||||
"version": "0.7.18",
|
||||
"version": "0.7.42",
|
||||
"author": {
|
||||
"name": "HeyGen",
|
||||
"email": "hyperframes@heygen.com",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hyperframes",
|
||||
"version": "0.7.18",
|
||||
"version": "0.7.42",
|
||||
"description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website-to-video capture for HyperFrames.",
|
||||
"author": {
|
||||
"name": "HeyGen",
|
||||
@@ -33,7 +33,7 @@
|
||||
"shortDescription": "Write HTML, render video",
|
||||
"longDescription": "Build videos from HTML with HyperFrames. Author compositions with HTML, CSS, Tailwind v4 browser-runtime styles, GSAP, Anime.js, Lottie, Three.js, and WAAPI adapter patterns, use the CLI for the dev loop (init/preview/render), preprocess assets (tts/transcribe/remove-background) for compositions, install reusable registry blocks and components, and turn any website into a video with the 7-step capture-to-video pipeline.",
|
||||
"developerName": "HeyGen",
|
||||
"category": "Design",
|
||||
"category": "Creativity",
|
||||
"capabilities": ["Read", "Write"],
|
||||
"websiteURL": "https://hyperframes.heygen.com",
|
||||
"defaultPrompt": [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://cursor.com/schemas/cursor-plugin/plugin.json",
|
||||
"name": "hyperframes",
|
||||
"displayName": "HyperFrames by HeyGen",
|
||||
"version": "0.7.18",
|
||||
"version": "0.7.42",
|
||||
"description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website-to-video capture for HyperFrames.",
|
||||
"author": {
|
||||
"name": "HeyGen",
|
||||
|
||||
+171
-1
@@ -14,6 +14,8 @@
|
||||
"packages/producer/src/runtime-conformance.ts",
|
||||
"packages/producer/src/benchmark.ts",
|
||||
"packages/producer/scripts/generate-font-data.ts",
|
||||
"packages/producer/we-render.mjs",
|
||||
"packages/producer/scripts/validate-fast-video.ts",
|
||||
"packages/cli/scripts/generate-font-data.ts",
|
||||
"packages/engine/scripts/test-fitTextFontSize-browser.ts",
|
||||
"packages/aws-lambda/scripts/*.ts",
|
||||
@@ -51,6 +53,11 @@
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"docs/**",
|
||||
// Chrome browser binaries downloaded by puppeteer — not project source.
|
||||
"chrome/**",
|
||||
// Standalone spike scripts and benchmark runners used during R&D.
|
||||
"packages/engine/spikes/**",
|
||||
"packages/producer/de-*.mjs",
|
||||
"packages/producer/tests/**",
|
||||
"packages/player/tests/**",
|
||||
"packages/engine/tests/**",
|
||||
@@ -80,6 +87,22 @@
|
||||
"packages/cli/src/cloud/_gen/**",
|
||||
],
|
||||
"ignoreExports": [
|
||||
// drawElementService is the bottom of the fast-capture Graphite stack
|
||||
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
|
||||
// a per-PR audit diffing against the merge base sees these exports as
|
||||
// unused. Consumed for real once the stack merges; safe to drop this
|
||||
// entry after #1919 lands.
|
||||
{
|
||||
"file": "packages/engine/src/services/drawElementService.ts",
|
||||
"exports": [
|
||||
"instrumentAcceleratedCanvases",
|
||||
"injectDrawElementCanvas",
|
||||
"captureDrawElementFrame",
|
||||
"initDrawElementWorkerEncode",
|
||||
"cleanupDrawElementWorkerEncode",
|
||||
"produceDrawElementFrame",
|
||||
],
|
||||
},
|
||||
// CLI command files: every command exports a const `examples` per the
|
||||
// convention documented in CLAUDE.md. This is a namespace barrel, not a
|
||||
// collision.
|
||||
@@ -126,7 +149,13 @@
|
||||
// Exported for render.test.ts (exported-for-tests pattern).
|
||||
{
|
||||
"file": "packages/cli/src/commands/render.ts",
|
||||
"exports": ["resolveBrowserGpuForCli", "renderLocal"],
|
||||
"exports": ["resolveBrowserGpuForCli", "renderLocal", "checkRenderResolutionPreflight"],
|
||||
},
|
||||
// initThreeDProjectionInPage: passed to page.evaluate() for browser-side execution —
|
||||
// Puppeteer serializes it at runtime, not an import-graph consumer.
|
||||
{
|
||||
"file": "packages/engine/src/services/threeDProjection.ts",
|
||||
"exports": ["initThreeDProjectionInPage"],
|
||||
},
|
||||
// captureCost.ts: constants and helpers consumed by the runCaptureCalibration
|
||||
// orchestration function and tests, but the entry-point graph doesn't
|
||||
@@ -185,6 +214,12 @@
|
||||
"file": "packages/studio/src/player/components/timelineCallbacks.ts",
|
||||
"exports": ["*"],
|
||||
},
|
||||
// Shared timeline DOM barrel for downstream consumers; fallow does not
|
||||
// trace all re-export-only modules.
|
||||
{
|
||||
"file": "packages/studio/src/player/lib/timelineDOM.ts",
|
||||
"exports": ["*"],
|
||||
},
|
||||
// gsapTargetCache: cached O(1) GSAP target lookup, consumed by
|
||||
// useDomEditCommits and intended to replace the local copy in
|
||||
// useDomGeometryCommits once callers migrate.
|
||||
@@ -202,6 +237,20 @@
|
||||
"file": "packages/studio/src/utils/timelineElementSplit.ts",
|
||||
"exports": ["buildPatchTarget", "readFileContent"],
|
||||
},
|
||||
// freezeUrl and freezeLocalFile are public API re-exported from the figma
|
||||
// barrel (index.ts) for Task 4 manifest flow and the /figma skill integration;
|
||||
// not yet imported by current code outside the module.
|
||||
{
|
||||
"file": "packages/core/src/figma/freeze.ts",
|
||||
"exports": ["freezeUrl", "freezeLocalFile"],
|
||||
},
|
||||
// mediaDir, typeDirPath, isFigmaManifestRecord: re-exported from the figma
|
||||
// barrel (index.ts) via manifest.ts per Task 8 wiring. Consumed only by the
|
||||
// /figma skill integration, not by code in the current codebase.
|
||||
{
|
||||
"file": "packages/core/src/figma/manifest.ts",
|
||||
"exports": ["mediaDir", "typeDirPath", "isFigmaManifestRecord"],
|
||||
},
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
// Runtime/dynamic deps not visible to static analysis: tsup `external`,
|
||||
@@ -250,6 +299,10 @@
|
||||
// require intrusive middleware changes beyond this PR's scope.
|
||||
"minLines": 6,
|
||||
"ignore": [
|
||||
// sourcePatcher.ts: pre-existing internal clones between the inline-style
|
||||
// and attribute tag-patchers; only the PatchOperation type gained two
|
||||
// optional fields here, but the line shift makes fallow re-flag them.
|
||||
"packages/studio/src/utils/sourcePatcher.ts",
|
||||
// gsapParser.ts: recast/babel GSAP writer — intentional duplication between
|
||||
// recast and acorn parallel implementations (pre-existing, moved from core).
|
||||
"packages/parsers/src/gsapParser.ts",
|
||||
@@ -325,6 +378,38 @@
|
||||
// → assert-remove-spawn shape; each verifies a distinct prune behavior, so
|
||||
// extracting the shared scaffold would obscure what each case asserts.
|
||||
"packages/cli/src/commands/skills.test.ts",
|
||||
// figma test files: freeze.test.ts and manifest.test.ts share a common
|
||||
// arrange/act/assert setup preamble (file creation + test dir handling) that
|
||||
// is minimal boilerplate; collapsing it into a shared fixture would reduce
|
||||
// readability of each test's independent setup.
|
||||
"packages/core/src/figma/freeze.test.ts",
|
||||
"packages/core/src/figma/manifest.test.ts",
|
||||
// layout-audit.browser.test.ts: parallel arrange/act/assert cases per audit
|
||||
// rule (overflow / overlap / occlusion) that each install their own mocked
|
||||
// geometry + computed-style before asserting. The clone groups are this
|
||||
// pre-existing per-rule scaffold; adding a clip-path case shifts lines and
|
||||
// re-flags it. Collapsing the per-rule installers would obscure each case.
|
||||
"packages/cli/src/commands/layout-audit.browser.test.ts",
|
||||
// gsapParserAcorn.motionEval.test.ts: parallel arrange/act/assert cases for
|
||||
// the staggered-collection honesty pass (.from reveal vs .to landing on the
|
||||
// rest pose). Each asserts a distinct keyframe shape; collapsing the shared
|
||||
// parse/keyframes-extraction scaffold would obscure what each case verifies.
|
||||
"packages/core/src/parsers/gsapParserAcorn.motionEval.test.ts",
|
||||
// Studio UX-review sweep (148 findings fixed across ~90 studio files):
|
||||
// heavily-edited files re-flag line-shifted inherited complexity, and the
|
||||
// new small handlers (a11y keydown/menu/dialog/error-state paths, mostly
|
||||
// 5-6 cyclomatic) trip the CRAP threshold absent coverage data. Reviewed
|
||||
// individually in the studio-ux PR stack rather than refactored here.
|
||||
"packages/studio/src/player/components/TimelineClipDiamonds.test.tsx",
|
||||
"packages/studio/src/hooks/useFileManager.ts",
|
||||
"packages/studio/src/captions/hooks/useCaptionSync.ts",
|
||||
"packages/studio/vite.adapter.ts",
|
||||
"packages/studio/src/components/sidebar/AudioRow.tsx",
|
||||
"packages/studio/src/components/sidebar/AssetsTab.tsx",
|
||||
"packages/studio/src/components/editor/propertyPanelFill.tsx",
|
||||
"packages/studio/src/captions/store.ts",
|
||||
"packages/studio/src/captions/components/CaptionOverlay.tsx",
|
||||
"packages/cli/src/server/studioServer.ts",
|
||||
],
|
||||
},
|
||||
"health": {
|
||||
@@ -333,6 +418,11 @@
|
||||
// complexity pre-dates the computed-timeline work. Exempted at file level
|
||||
// rather than refactored as scope creep.
|
||||
"ignore": [
|
||||
// sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations /
|
||||
// patch*InTag pre-date this PR; only the PatchOperation type gained two
|
||||
// optional fields, but the line-shift fingerprint re-flags the inherited
|
||||
// complexity.
|
||||
"packages/studio/src/utils/sourcePatcher.ts",
|
||||
// timeline.ts: collectRuntimeTimelinePayload (CRITICAL) pre-dates this PR;
|
||||
// only an import line changed here (slideshow/sceneId → slideshow/index),
|
||||
// but the line-shift fingerprint makes fallow re-flag inherited complexity.
|
||||
@@ -409,6 +499,86 @@
|
||||
// gsapRuntimeReaders.ts: pre-existing complexity in readAllAnimatedProperties;
|
||||
// line-shift fingerprint from import-path updates triggers the violation.
|
||||
"packages/studio/src/hooks/gsapRuntimeReaders.ts",
|
||||
// In-page audit scripts: single large browser-side IIFE wrappers
|
||||
// (auditLayout/__contrastAudit) with pre-existing CRITICAL helpers
|
||||
// (textOverflowFixHint, selectorFor, the WCAG walk). Adding the small
|
||||
// clip-path probe helpers shifts every function below it, so the line-shift
|
||||
// fingerprint re-flags inherited complexity even though the new helpers are
|
||||
// all at the lowest tier (<=5 cyclomatic).
|
||||
"packages/cli/src/commands/layout-audit.browser.js",
|
||||
"packages/cli/src/commands/contrast-audit.browser.js",
|
||||
// lottie.ts: the flagged `seek` handler (lottie-web/dotLottie dual-API
|
||||
// dispatch, unchanged by the duration-auto-inference work in this PR)
|
||||
// pre-dates this PR's scope. New functions added earlier in the file
|
||||
// (getInferredDurationSeconds + helpers) shifted its line numbers,
|
||||
// breaking fallow's inherited-detection fingerprint. File-level
|
||||
// exemption avoids the line-shift problem for this inherited finding.
|
||||
"packages/core/src/runtime/adapters/lottie.ts",
|
||||
// info.ts: the flagged `run` command handler pre-dates the keyframes-cli
|
||||
// PR; that PR only added the orientation()/durationFromHtml() helpers and
|
||||
// swapped a few `maxEnd` reads for the new `duration` variable, without
|
||||
// adding branches. The line-shift fingerprint re-flags the inherited
|
||||
// complexity even though the logic is unchanged.
|
||||
"packages/cli/src/commands/info.ts",
|
||||
// gsapParserAcorn.ts: the flagged `sameMemberAccess` (structural equality
|
||||
// of two member-access nodes) pre-dates this PR's motion-eval work
|
||||
// (added in #1760, unchanged since). The new const-folding / set-seeding /
|
||||
// label-position code added earlier in the file shifted its line number,
|
||||
// so the line-shift fingerprint re-flags this inherited finding.
|
||||
"packages/parsers/src/gsapParserAcorn.ts",
|
||||
// isFigmaManifestRecord: type guard with 19 cyclomatic due to chained
|
||||
// field validation (id/type/path/source types + value shape guards).
|
||||
// This is the correct shape for a discriminating type guard; refactoring
|
||||
// into smaller guards would obscure the unified validation contract.
|
||||
"packages/core/src/figma/manifest.ts",
|
||||
// Studio UX-review sweep (148 findings fixed across ~90 studio files):
|
||||
// heavily-edited files re-flag line-shifted inherited complexity, and the
|
||||
// new small handlers (a11y keydown/menu/dialog/error-state paths, mostly
|
||||
// 5-6 cyclomatic) trip the CRAP threshold absent coverage data. Reviewed
|
||||
// individually in the studio-ux PR stack rather than refactored here.
|
||||
"packages/studio/src/App.tsx",
|
||||
"packages/studio/src/captions/components/CaptionAnimationPanel.tsx",
|
||||
"packages/studio/src/captions/components/CaptionOverlay.tsx",
|
||||
"packages/studio/src/captions/components/CaptionOverlayUtils.ts",
|
||||
"packages/studio/src/captions/components/CaptionPropertyPanel.tsx",
|
||||
"packages/studio/src/captions/components/shared.tsx",
|
||||
"packages/studio/src/captions/hooks/useCaptionSync.ts",
|
||||
"packages/studio/src/components/AskAgentModal.tsx",
|
||||
"packages/studio/src/components/editor/BlockParamsPanel.tsx",
|
||||
"packages/studio/src/components/editor/DomEditOverlay.tsx",
|
||||
"packages/studio/src/components/editor/FileTree.tsx",
|
||||
"packages/studio/src/components/editor/FileTreeNodes.tsx",
|
||||
"packages/studio/src/components/editor/LayersPanel.tsx",
|
||||
"packages/studio/src/components/editor/MotionPathNode.tsx",
|
||||
"packages/studio/src/components/editor/PropertyPanel.tsx",
|
||||
"packages/studio/src/components/editor/propertyPanelFont.tsx",
|
||||
"packages/studio/src/components/editor/propertyPanelMediaSection.tsx",
|
||||
"packages/studio/src/components/editor/propertyPanelStyleSections.tsx",
|
||||
"packages/studio/src/components/editor/Transform3DCube.tsx",
|
||||
"packages/studio/src/components/LintModal.tsx",
|
||||
"packages/studio/src/components/MediaPreview.tsx",
|
||||
"packages/studio/src/components/nle/NLELayout.tsx",
|
||||
"packages/studio/src/components/nle/NLEPreview.tsx",
|
||||
"packages/studio/src/components/sidebar/AudioRow.tsx",
|
||||
"packages/studio/src/components/sidebar/BlocksTab.tsx",
|
||||
"packages/studio/src/components/sidebar/CompositionsTab.tsx",
|
||||
"packages/studio/src/components/sidebar/LeftSidebar.tsx",
|
||||
"packages/studio/src/components/storyboard/StoryboardLoaded.tsx",
|
||||
"packages/studio/src/components/StudioPreviewArea.tsx",
|
||||
"packages/studio/src/components/StudioRightPanel.tsx",
|
||||
"packages/studio/src/components/StudioToast.tsx",
|
||||
"packages/studio/src/components/ui/Tooltip.tsx",
|
||||
"packages/studio/src/components/ui/useDialogBehavior.ts",
|
||||
"packages/studio/src/hooks/useAppHotkeys.ts",
|
||||
"packages/studio/src/hooks/useCaptionDetection.ts",
|
||||
"packages/studio/src/hooks/useFileManager.ts",
|
||||
"packages/studio/src/hooks/useFrameCapture.ts",
|
||||
"packages/studio/src/hooks/usePanelLayout.ts",
|
||||
"packages/studio/src/player/components/menuKeyboardNav.ts",
|
||||
"packages/studio/src/player/components/Timeline.tsx",
|
||||
"packages/studio/src/player/components/TimelineCanvas.tsx",
|
||||
"packages/studio/src/player/components/TimelineClip.tsx",
|
||||
"packages/studio/src/player/components/TimelineClipDiamonds.tsx",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ packages/producer/tests/**/*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
packages/producer/tests/**/*.mov filter=lfs diff=lfs merge=lfs -text
|
||||
packages/producer/tests/**/*.webm filter=lfs diff=lfs merge=lfs -text
|
||||
packages/producer/tests/**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||
packages/producer/tests/**/output/compiled.html filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# ONNX models must ALWAYS use LFS regardless of location: a 31 MB ppmattingv2
|
||||
# model was once committed raw into skills/ then deleted — but a raw commit
|
||||
|
||||
@@ -79,9 +79,12 @@ jobs:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: corepack enable
|
||||
- run: corepack prepare pnpm@10.17.1 --activate
|
||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bun run build
|
||||
- run: bun run verify:packed-manifests
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Validates the experimental fast-capture (drawElementImage) VIDEO path on a
|
||||
# native amd64 Linux runner — where chrome-headless-shell's per-frame BeginFrame
|
||||
# drives a real paint each frame, so drawElementImage's snapshot is fresh and
|
||||
# video captures correctly. This is the one part of the feature that could not be
|
||||
# validated locally (macOS has no BeginFrame; Docker-on-rosetta hung).
|
||||
# See docs/fast-capture-limitations.md (Limitation 2).
|
||||
#
|
||||
# Manual trigger: Actions → "Fast-capture video validation" → Run workflow.
|
||||
name: Fast-capture video validation
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
# Manual only. NOTE: currently fails by design — fast capture cannot capture
|
||||
# video on any platform yet (see docs/fast-capture-limitations.md, Limitation 2).
|
||||
# This is the regression gate for if/when fast video is implemented.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
composition:
|
||||
description: Test composition to render (must contain <video>)
|
||||
default: sub-composition-video
|
||||
min_psnr:
|
||||
description: Min fast-vs-baseline PSNR (dB) to pass
|
||||
default: "25"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Build test Docker image (cached)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.test
|
||||
load: true
|
||||
tags: hyperframes-producer:test
|
||||
cache-from: type=gha,scope=regression-test-image
|
||||
cache-to: type=gha,mode=max,scope=regression-test-image
|
||||
|
||||
- name: Validate fast-capture video (drawElement + BeginFrame)
|
||||
run: |
|
||||
docker run --rm \
|
||||
--security-opt seccomp=unconfined \
|
||||
--shm-size=4g \
|
||||
-e PRODUCER_VALIDATE_COMP='${{ inputs.composition }}' \
|
||||
-e PRODUCER_VALIDATE_MIN_PSNR='${{ inputs.min_psnr }}' \
|
||||
--workdir /app/packages/producer \
|
||||
--entrypoint bunx \
|
||||
hyperframes-producer:test tsx scripts/validate-fast-video.ts
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Sync skills to ClawHub
|
||||
|
||||
# Publishes changed skills under skills/ to ClawHub (https://clawhub.ai/heygen-com)
|
||||
# whenever they land on main. `clawhub sync` only publishes skills whose content
|
||||
# changed vs. the registry, auto-bumping the patch version — unchanged skills are
|
||||
# a no-op, so this is safe to run on every push.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "skills/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Preview what would publish without publishing"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Serialize runs so two pushes don't race on the same skill version.
|
||||
concurrency:
|
||||
group: clawhub-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Publish changed skills
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
CLAWHUB_DISABLE_TELEMETRY: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install ClawHub CLI
|
||||
run: npm i -g clawhub@0.23.1
|
||||
|
||||
- name: Authenticate
|
||||
env:
|
||||
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
run: clawhub login --token "$CLAWHUB_TOKEN" --no-input --label "hyperframes CI"
|
||||
|
||||
- name: Sync skills
|
||||
env:
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
run: |
|
||||
FLAGS=(
|
||||
--all
|
||||
--owner heygen-com
|
||||
--bump patch
|
||||
--changelog "Synced from ${GITHUB_SHA:0:7} (${GITHUB_REF_NAME})"
|
||||
--source-repo "$GITHUB_REPOSITORY"
|
||||
--source-commit "$GITHUB_SHA"
|
||||
--source-ref "$GITHUB_REF"
|
||||
)
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
FLAGS+=(--dry-run)
|
||||
fi
|
||||
clawhub sync "${FLAGS[@]}"
|
||||
+2
-1
@@ -10,6 +10,7 @@
|
||||
"node_modules/",
|
||||
"playground/",
|
||||
"packages/producer/tests/**/src/vendor/**",
|
||||
"registry/blocks/**/lib/*.iife.js"
|
||||
"registry/blocks/**/lib/*.iife.js",
|
||||
"chrome/"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,15 +10,17 @@ This repo ships AI agent skills via [vercel-labs/skills](https://github.com/verc
|
||||
npx skills add heygen-com/hyperframes
|
||||
```
|
||||
|
||||
**Video-creation workflows** route through one entry skill — read `/hyperframes` first: it orients you to the whole surface and maps "make me a video" intent to a concrete workflow. Consult it before invoking a specific workflow:
|
||||
**Creation workflows** route through one entry skill — read `/hyperframes` first: it orients you to the whole surface and maps "make me a…" intent — usually a video, but also a navigable deck (`/slideshow`) or a composition port (`/remotion-to-hyperframes`) — to a concrete workflow. Consult it before invoking a specific workflow:
|
||||
|
||||
- `/product-launch-video` — a **product** URL (or a pre-written script / text brief in no-capture mode) → product launch / promo video, up to ~3 min (sweet spot ~30-90s).
|
||||
- `/website-to-video` — a **general** website / URL → a video _of_ the site (tour / showcase / social clip from captured screenshots + assets); for a product **launch / promo**, use `/product-launch-video`.
|
||||
- `/faceless-explainer` — arbitrary text, **no URL and no website capture** → faceless explainer, up to ~3 min (sweet spot ~30-90s); every visual is LLM-invented (typography / abstract graphics / diagram / data-viz).
|
||||
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (verbatim rail + embedded climax, or pure-cinematic embed); the footage itself is untouched (no NLE-style editing).
|
||||
- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage packaged with designed **graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath, footage untouched. Replaces the removed `/footage-recut`. For plain captions/subtitles → `/embedded-captions`.
|
||||
- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage packaged with designed **graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath, footage untouched. For plain captions/subtitles → `/embedded-captions`.
|
||||
- `/pr-to-video` — a GitHub PR (URL / `owner/repo#N` / "this PR") → code-change explainer, up to ~3 min (changelog / feature reveal / fix / refactor). A PR link, not a product website.
|
||||
- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
|
||||
- `/music-to-video` — a **music track** (audio file, or video to pull audio from) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
|
||||
- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
|
||||
- `/general-video` — fallback for any other video creation (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition); the original hyperframes flow — design → plan → layout → build → validate, any length.
|
||||
|
||||
**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) video composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
|
||||
|
||||
@@ -4,14 +4,16 @@ Open-source video rendering framework: write HTML, render video.
|
||||
|
||||
## Skills
|
||||
|
||||
This repo ships 19 AI agent skills via [vercel-labs/skills](https://github.com/vercel-labs/skills). Install them before writing compositions — they encode framework-specific patterns that generic docs don't cover.
|
||||
This repo ships 20 AI agent skills via [vercel-labs/skills](https://github.com/vercel-labs/skills). Install them before writing compositions — they encode framework-specific patterns that generic docs don't cover.
|
||||
|
||||
```bash
|
||||
npx skills add heygen-com/hyperframes # interactive picker
|
||||
npx skills add heygen-com/hyperframes --all # install all 19 (skips picker)
|
||||
npx skills add heygen-com/hyperframes --skill <name> # just one (bare name, no leading slash)
|
||||
npx skills add heygen-com/hyperframes --full-depth # interactive picker
|
||||
npx skills add heygen-com/hyperframes --all --full-depth # install all 20 (skips picker)
|
||||
npx skills add heygen-com/hyperframes --skill <name> --full-depth # just one (bare name, no leading slash)
|
||||
```
|
||||
|
||||
Keep `--full-depth`: it installs the current `main`. Without it, `skills add` fetches the skills.sh registry blob, which lags `main` by hours (you'd get a stale skill). `hyperframes skills update` already uses full-depth.
|
||||
|
||||
**`/hyperframes` is the entry skill — read it first.** It's the capability map for the domain skills below AND the intent router for the creation workflows. The full README skills section mirrors this list; keep them in sync (see "Skill catalog maintenance" below).
|
||||
|
||||
### Creation workflows
|
||||
@@ -34,20 +36,22 @@ Atomic capabilities the creation workflows compose against — pull one when you
|
||||
|
||||
- `/hyperframes-core` — the composition contract: `data-*` timing attributes, `class="clip"`, tracks, sub-compositions, variables, framework-owned media playback, determinism rules. Read before writing composition HTML.
|
||||
- `/hyperframes-animation` — all animation knowledge: atomic motion rules, scene blueprints, transitions, runtime adapters (GSAP default, plus Lottie / Three.js / Anime.js / CSS / WAAPI / TypeGPU).
|
||||
- `/hyperframes-keyframes` — seek-safe keyframe authoring across runtimes: GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, text trails, 3D depth; plus `hyperframes keyframes` diagnostics for surfacing and verifying rendered motion.
|
||||
- `/hyperframes-creative` — non-animation creative direction: `frame.md` / `design.md` handling, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns.
|
||||
- `/hyperframes-media` — audio + media: TTS voiceover, background music, sound effects, Whisper transcription, background removal, caption authoring (one shared `scripts/audio.mjs` engine, multi-provider).
|
||||
- `/media-use` — resolve any media need (BGM, SFX, image, icon) into a frozen local file + ledger record. One verb (`resolve`) over the HeyGen catalog with manifest tracking; keeps search noise on disk.
|
||||
- `/media-use` — the media OS: resolve any media need (BGM, SFX, image, icon, voice) into a frozen local file + ledger record; generate via TTS / music / image models when the catalog misses; transcribe, caption, remove backgrounds, and reuse assets across projects. One shared `scripts/audio.mjs` engine + manifest tracking; keeps search noise on disk.
|
||||
- `/hyperframes-cli` — CLI dev loop: `init`, `add`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, `lambda` (AWS Lambda cloud rendering).
|
||||
- `/hyperframes-registry` — install and wire registry blocks and components into compositions via `hyperframes add`. Covers authoring a new block or component to contribute upstream.
|
||||
- `/figma` — import Figma assets, tokens, components, and storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI) plus Motion animations (MCP) and shaders (MCP source / native export) into a composition.
|
||||
|
||||
## Skill catalog maintenance
|
||||
|
||||
When adding a new skill, or substantially renaming / repurposing an existing one, update all three agent-facing discoverability surfaces in lockstep:
|
||||
When adding a new skill, or substantially renaming / repurposing an existing one, update all agent-facing discoverability surfaces in lockstep:
|
||||
|
||||
1. The skill list above (CLAUDE.md) AND the `## Skills` section in `README.md` AND `docs/guides/skills.mdx` (rendered at [hyperframes.heygen.com/guides/skills](https://hyperframes.heygen.com/guides/skills)). Out-of-date entries silently kill discovery.
|
||||
2. If the skill changes the routing surface for "make a video" requests, also update the capability map and intent router in `skills/hyperframes/SKILL.md` — that's the canonical router agents read first.
|
||||
3. Mirror the Router / Creation workflows / Domain skills grouping across all three surfaces so a skill always lives in the same column.
|
||||
4. Skill count appears in the README and CLAUDE.md intro lines ("19 AI agent skills…") — update on add/remove. The `docs/guides/skills.mdx` page deliberately omits a count to avoid drift; keep it count-free.
|
||||
1. The skill list above (CLAUDE.md) AND the workflow list in the root `AGENTS.md` (it carries workflows only, no domain-skill section) AND the `## Skills` section in `README.md` AND `docs/guides/skills.mdx` (rendered at [hyperframes.heygen.com/guides/skills](https://hyperframes.heygen.com/guides/skills)). Out-of-date entries silently kill discovery.
|
||||
2. The scaffolded project template `packages/cli/src/templates/_shared/CLAUDE.md` + `AGENTS.md` — written into every `hyperframes init` project, so a stale entry there ships to users. The two template files must stay byte-identical.
|
||||
3. If the skill changes the routing surface for "make a video" requests, also update the capability map and intent router in `skills/hyperframes/SKILL.md` — that's the canonical router agents read first.
|
||||
4. Mirror the Router / Creation workflows / Domain skills grouping across all surfaces so a skill always lives in the same column.
|
||||
5. Skill count appears in the README and CLAUDE.md intro lines ("20 AI agent skills…") — update on add/remove. The `docs/guides/skills.mdx` page and the CLI templates deliberately omit a count to avoid drift; keep them count-free.
|
||||
|
||||
The skill's own `SKILL.md` frontmatter `description:` is the source of truth for the one-line "use when" blurb; copy from there into the catalog rather than paraphrasing.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://static.heygen.ai/hyperframes-oss/docs/images/hyperframes-logo-motion-1280.webp" alt="HyperFrames demo: HTML code on the left transforms into a rendered video on the right" width="800">
|
||||
<img src="docs/public/images/hyperframes-logo-motion-1280-trimmed.webp" alt="HyperFrames demo: HTML code on the left transforms into a rendered video on the right" width="800">
|
||||
</p>
|
||||
|
||||
HyperFrames is an open-source framework for turning HTML, CSS, media, and seekable animations into deterministic MP4 videos. Use it locally with the CLI, from AI coding agents with skills, or as the rendering core behind hosted authoring workflows.
|
||||
@@ -38,9 +38,11 @@ HyperFrames is an open-source framework for turning HTML, CSS, media, and seekab
|
||||
Install the HyperFrames skills, then describe the video you want:
|
||||
|
||||
```bash
|
||||
npx skills add heygen-com/hyperframes
|
||||
npx skills add heygen-com/hyperframes --full-depth --yes
|
||||
```
|
||||
|
||||
> `--full-depth` does a full clone of the repo's current `main`. Without it, `skills add` fetches the skills.sh registry blob, which lags `main` by hours — you'd get an older copy of a skill. (`hyperframes skills update` already installs full-depth.)
|
||||
|
||||
Try a prompt like:
|
||||
|
||||
> Using `/hyperframes`, create a 10-second product intro with a fade-in title, a background video, and subtle background music.
|
||||
@@ -49,9 +51,9 @@ The skills teach agents the HyperFrames production loop: plan the video, write v
|
||||
|
||||
## Skills
|
||||
|
||||
HyperFrames ships 19 skills agents load on demand. Read `/hyperframes` first — it's the router and capability map; it picks a workflow for any "make me a video" request and points to the domain skills below.
|
||||
HyperFrames ships 20 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.
|
||||
|
||||
Run `npx skills add heygen-com/hyperframes` for the interactive picker, `npx skills add heygen-com/hyperframes --all` to install all 19 at once (skips the picker), or `npx skills add heygen-com/hyperframes --skill <name>` for just one (bare name, no leading `/`).
|
||||
Run `npx skills add heygen-com/hyperframes --full-depth` for the interactive picker, `npx skills add heygen-com/hyperframes --all --full-depth` to install all 20 at once (skips the picker), or `npx skills add heygen-com/hyperframes --skill <name> --full-depth` for just one (bare name, no leading `/`). Keep `--full-depth` — it installs the current `main`; without it `skills add` fetches the skills.sh blob, which lags by hours.
|
||||
|
||||
### Router
|
||||
|
||||
@@ -79,15 +81,16 @@ Run `npx skills add heygen-com/hyperframes` for the interactive picker, `npx ski
|
||||
|
||||
Atomic capabilities the creation workflows compose against — pull one when you need that specific layer.
|
||||
|
||||
| Skill | Covers |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/hyperframes-core` | The composition contract — `data-*` timing attributes, `class="clip"`, tracks, sub-compositions, variables, framework-owned media playback, determinism rules. |
|
||||
| `/hyperframes-animation` | All animation knowledge — atomic motion rules, scene blueprints, transitions, runtime adapters (GSAP / Lottie / Three.js / Anime.js / CSS / WAAPI / TypeGPU). |
|
||||
| `/hyperframes-creative` | Non-animation creative direction — `frame.md` / `design.md`, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns. |
|
||||
| `/hyperframes-media` | Audio + media — TTS voiceover, background music, sound effects, Whisper transcription, background removal, caption authoring (one shared audio engine). |
|
||||
| `/media-use` | Resolve any media need (BGM, SFX, image, icon) into a frozen local file + ledger record. One verb (`resolve`) over the HeyGen catalog with manifest tracking. |
|
||||
| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress`). |
|
||||
| `/hyperframes-registry` | Install and wire registry blocks and components into compositions via `hyperframes add`. Authoring a new block or component to contribute upstream. |
|
||||
| Skill | Covers |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/hyperframes-core` | The composition contract — `data-*` timing attributes, `class="clip"`, tracks, sub-compositions, variables, framework-owned media playback, determinism rules. |
|
||||
| `/hyperframes-animation` | All animation knowledge — atomic motion rules, scene blueprints, transitions, runtime adapters (GSAP / Lottie / Three.js / Anime.js / CSS / WAAPI / TypeGPU). |
|
||||
| `/hyperframes-keyframes` | Seek-safe keyframe authoring across runtimes — GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, 3D depth — plus `hyperframes keyframes` diagnostics for rendered motion. |
|
||||
| `/hyperframes-creative` | Non-animation creative direction — `frame.md` / `design.md`, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns. |
|
||||
| `/media-use` | The media OS — resolve any media need (BGM, SFX, image, icon, voice) into a frozen local file + ledger record, generate via TTS/music/image models when the catalog misses, transcribe, caption, remove backgrounds, and reuse assets across projects. One shared audio engine + manifest tracking. |
|
||||
| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress`). |
|
||||
| `/hyperframes-registry` | Install and wire registry blocks and components into compositions via `hyperframes add`. Authoring a new block or component to contribute upstream. |
|
||||
| `/figma` | Import Figma assets, tokens, components, and storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI) plus Motion animations (MCP) and shaders (MCP source / native export) into a composition. |
|
||||
|
||||
For visual design handoff workflows, see the [Claude Design guide](https://hyperframes.heygen.com/guides/claude-design) and [Open Design guide](https://hyperframes.heygen.com/guides/open-design).
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"packages/aws-lambda": {
|
||||
"name": "@hyperframes/aws-lambda",
|
||||
"version": "0.7.16",
|
||||
"version": "0.7.22",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/client-sfn": "^3.700.0",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@hyperframes/cli",
|
||||
"version": "0.7.16",
|
||||
"version": "0.7.22",
|
||||
"bin": {
|
||||
"hyperframes": "./dist/cli.js",
|
||||
},
|
||||
@@ -103,7 +103,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@hyperframes/core",
|
||||
"version": "0.7.16",
|
||||
"version": "0.7.22",
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
"@hyperframes/lint": "workspace:*",
|
||||
@@ -128,7 +128,7 @@
|
||||
},
|
||||
"packages/engine": {
|
||||
"name": "@hyperframes/engine",
|
||||
"version": "0.7.16",
|
||||
"version": "0.7.22",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hyperframes/core": "workspace:^",
|
||||
@@ -146,7 +146,7 @@
|
||||
},
|
||||
"packages/gcp-cloud-run": {
|
||||
"name": "@hyperframes/gcp-cloud-run",
|
||||
"version": "0.7.16",
|
||||
"version": "0.7.22",
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.14.0",
|
||||
"@google-cloud/workflows": "^4.2.0",
|
||||
@@ -166,9 +166,10 @@
|
||||
},
|
||||
"packages/lint": {
|
||||
"name": "@hyperframes/lint",
|
||||
"version": "0.7.16",
|
||||
"version": "0.7.22",
|
||||
"dependencies": {
|
||||
"@hyperframes/parsers": "workspace:*",
|
||||
"linkedom": "^0.18.12",
|
||||
"postcss": "^8.5.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -181,7 +182,7 @@
|
||||
},
|
||||
"packages/parsers": {
|
||||
"name": "@hyperframes/parsers",
|
||||
"version": "0.7.16",
|
||||
"version": "0.7.22",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.0",
|
||||
"acorn": "^8.17.0",
|
||||
@@ -201,7 +202,7 @@
|
||||
},
|
||||
"packages/player": {
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.7.16",
|
||||
"version": "0.7.22",
|
||||
"dependencies": {
|
||||
"@hyperframes/core": "workspace:*",
|
||||
},
|
||||
@@ -216,7 +217,7 @@
|
||||
},
|
||||
"packages/producer": {
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.7.16",
|
||||
"version": "0.7.22",
|
||||
"dependencies": {
|
||||
"@fontsource/archivo-black": "^5.2.8",
|
||||
"@fontsource/eb-garamond": "^5.2.7",
|
||||
@@ -233,6 +234,7 @@
|
||||
"@hyperframes/core": "workspace:^",
|
||||
"@hyperframes/engine": "workspace:^",
|
||||
"@hyperframes/lint": "workspace:^",
|
||||
"@hyperframes/parsers": "workspace:^",
|
||||
"@hyperframes/studio-server": "workspace:^",
|
||||
"hono": "^4.6.0",
|
||||
"linkedom": "^0.18.12",
|
||||
@@ -259,7 +261,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@hyperframes/sdk",
|
||||
"version": "0.7.16",
|
||||
"version": "0.7.22",
|
||||
"dependencies": {
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@hyperframes/parsers": "workspace:*",
|
||||
@@ -267,6 +269,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"happy-dom": "^20.9.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.2.4",
|
||||
},
|
||||
@@ -285,7 +288,7 @@
|
||||
},
|
||||
"packages/shader-transitions": {
|
||||
"name": "@hyperframes/shader-transitions",
|
||||
"version": "0.7.16",
|
||||
"version": "0.7.22",
|
||||
"dependencies": {
|
||||
"html2canvas": "^1.4.1",
|
||||
},
|
||||
@@ -297,7 +300,7 @@
|
||||
},
|
||||
"packages/studio": {
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.7.16",
|
||||
"version": "0.7.22",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
@@ -318,6 +321,7 @@
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"bpm-detective": "^2.0.5",
|
||||
"dompurify": "^3.2.4",
|
||||
"gsap": "^3.13.0",
|
||||
"marked": "^14.1.4",
|
||||
"mediabunny": "^1.45.3",
|
||||
},
|
||||
@@ -344,7 +348,7 @@
|
||||
},
|
||||
"packages/studio-server": {
|
||||
"name": "@hyperframes/studio-server",
|
||||
"version": "0.7.16",
|
||||
"version": "0.7.22",
|
||||
"dependencies": {
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@hyperframes/parsers": "workspace:*",
|
||||
|
||||
@@ -8,6 +8,612 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio
|
||||
|
||||
{/* New release entries are prepended by `bun run changelog:draft <version> --write`. */}
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.42"
|
||||
description="Released - 2026-07-07"
|
||||
tags={["Release", "Producer,cli", "CLI", "Engine"]}
|
||||
>
|
||||
This release switches drawElement fast capture to single-worker streaming after benchmarks showed multi-page parallelism losing to priority inversion, and makes Windows rendering reliable by copying extracted frames when symlinks hit EPERM. It also hardens the CLI: render fps defaults to the composition's `data-fps`, corrupt browser downloads self-heal, and upgrade notices match the detected install method.
|
||||
|
||||
## Features
|
||||
|
||||
- **Producer,cli:** DrawElement priority inversion — single-worker streaming over auto-parallel ([924727a0b](https://github.com/heygen-com/hyperframes/commit/924727a0b462b63c28c68a5fdf971ba7a6b1a369), [#2026](https://github.com/heygen-com/hyperframes/pull/2026))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **CLI:** Upgrade and update notice use the detected install method ([4b3c73d94](https://github.com/heygen-com/hyperframes/commit/4b3c73d941f208834d913876b94757a767218c70))
|
||||
- **Engine:** Resolve relative data-start references in video-frame extraction ([4a36655b2](https://github.com/heygen-com/hyperframes/commit/4a36655b2bd966ed81afcdcd2afb3bb201810b09))
|
||||
- **Media Use:** Create the output dir before ElevenLabs TTS writes ([42a209545](https://github.com/heygen-com/hyperframes/commit/42a209545b55c62d0647e7770523e1ce47c2de67))
|
||||
- **Producer:** Fall back to copying extracted frames when symlink hits EPERM ([00b96d2ea](https://github.com/heygen-com/hyperframes/commit/00b96d2eaab071e65a02452b0416088866a2c451))
|
||||
- **Producer:** Copy extracted frames on Windows to avoid symlink EPERM ([1aa39d465](https://github.com/heygen-com/hyperframes/commit/1aa39d46538544c71ca98dfa3bff059a48ceaec9))
|
||||
- **CLI:** Default render fps to the composition's data-fps ([de27b4668](https://github.com/heygen-com/hyperframes/commit/de27b466808d1e1f020d06701c8ed1196a4d3029))
|
||||
- **CLI:** Re-download the browser when the cached archive is corrupt ([92f3116de](https://github.com/heygen-com/hyperframes/commit/92f3116dee8d10090d1e6d18647b9445ee965a01))
|
||||
- **CLI:** Lint sets process.exitCode instead of process.exit() to flush stdout ([4834de37f](https://github.com/heygen-com/hyperframes/commit/4834de37f4c94f05fc8ab3098d5f12e6486b18de))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.41...v0.7.42).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.41"
|
||||
description="Released - 2026-07-07"
|
||||
tags={["Release", "Media Use", "Engine"]}
|
||||
>
|
||||
This release makes media-use smarter about reusing existing assets instead of regenerating work, with more forgiving prompt matching and a more precise assets scan. It also fixes a render-engine edge case by pre-creating `__render_frame__` sibling directories during session initialization.
|
||||
|
||||
## Features
|
||||
|
||||
- **Media Use:** Agent-driven asset reuse (candidates + reuse) ([35e54cae1](https://github.com/heygen-com/hyperframes/commit/35e54cae1978e04d09ab4cc7c8627cdecdcf6421))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Engine:** Pre-create __render_frame__ siblings in initializeSession ([76204ec63](https://github.com/heygen-com/hyperframes/commit/76204ec6308a235eb0b77557d0a52657952572f4), [#2006](https://github.com/heygen-com/hyperframes/pull/2006))
|
||||
- **Media Use:** Forgiving prompt matching + precise assets/ scan ([b5383ded4](https://github.com/heygen-com/hyperframes/commit/b5383ded4249aca8f7d2fba2e2ab11e7beb2e751))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.40...v0.7.41).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.40"
|
||||
description="Released - 2026-07-07"
|
||||
tags={["Release", "CLI", "Engine,producer", "Studio"]}
|
||||
>
|
||||
The headline this release is a Studio timeline revamp: clips visible at the playhead are highlighted, tracks and individual elements can be hidden with a non-destructive `data-hidden` toggle that carries through to renders, and ruler zoom and gutter behavior are cleaner. It also fixes static-position dragging beside an animated rotation and keeps off-canvas indicators in sync with live layout.
|
||||
|
||||
## Features
|
||||
|
||||
- **Studio:** Timeline revamp with active-clip highlighting and hide controls ([037266e7](https://github.com/heygen-com/hyperframes/commit/037266e72bd808491a82008ca2520299fecef381), [#2017](https://github.com/heygen-com/hyperframes/pull/2017))
|
||||
- **CLI:** Associate signed-in HeyGen account with telemetry ([3d8372f8](https://github.com/heygen-com/hyperframes/commit/3d8372f880a6739ee3e0beea484905d76e5ad3a9), [#2020](https://github.com/heygen-com/hyperframes/pull/2020))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **CLI:** Figma component import survives unrenderable nodes ([4c8064d4](https://github.com/heygen-com/hyperframes/commit/4c8064d4d3d1ffe51a10b1c7eee79a49b2fc468f), [#2022](https://github.com/heygen-com/hyperframes/pull/2022))
|
||||
- **Studio:** Off-canvas indicators track live layout instead of going stale ([574da2b2](https://github.com/heygen-com/hyperframes/commit/574da2b215b29cc3e8288551ce37e7a06cdaa194), [#2018](https://github.com/heygen-com/hyperframes/pull/2018))
|
||||
- **Studio:** Static-position drag no longer freezes an element beside an animated rotation ([5d598354](https://github.com/heygen-com/hyperframes/commit/5d59835446bb426def56a791d19a99a6ebe283d3), [#2016](https://github.com/heygen-com/hyperframes/pull/2016))
|
||||
- **Skills:** Audit descriptions — trim routing prose, fix stale facts, add missing triggers ([306a291d](https://github.com/heygen-com/hyperframes/commit/306a291deaf8796cd2e9e34c406d707a7bdf3f7f), [#1990](https://github.com/heygen-com/hyperframes/pull/1990))
|
||||
|
||||
## Internal
|
||||
|
||||
- **Engine,producer:** Adopt requestPaint contract, retire autoAlpha rewrite ([337d0b51](https://github.com/heygen-com/hyperframes/commit/337d0b51bcd81184520b23259799d42aaa366ed2), [#2021](https://github.com/heygen-com/hyperframes/pull/2021))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.39...v0.7.40).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.39"
|
||||
description="Released - 2026-07-07"
|
||||
tags={["Release", "Engine,producer,cli", "Skills", "Studio"]}
|
||||
>
|
||||
Fast-capture self-verification now covers video compositions — drawElement init defers until the frame injector is attached, so probe-initialized video renders get the same ground-truth safety net as graphics comps. Also new: a warmup-robust `capture_p50_ms` render metric, a Parakeet ASR engine for `transcribe`, and the v2 media OS core with transcription-driven editing tools.
|
||||
|
||||
## Features
|
||||
|
||||
- **Engine,producer,cli:** Verify video comps via deferred DE init + capture p50 ([b26c27576](https://github.com/heygen-com/hyperframes/commit/b26c27576b71d9a86c57828f7e1712b14479edeb), [#2015](https://github.com/heygen-com/hyperframes/pull/2015))
|
||||
- **Skills:** Align easing docs with motion doctrine, add baked springEase ([47276edcc](https://github.com/heygen-com/hyperframes/commit/47276edcc601d3bc8f50f52773a4193415340a6d), [#1989](https://github.com/heygen-com/hyperframes/pull/1989))
|
||||
- **Media Use:** Transcription (parakeet), transcript-cut, audio-duck editing tools ([ac0153cb3](https://github.com/heygen-com/hyperframes/commit/ac0153cb30143e17264f20123d1c2a7074c87509))
|
||||
- **Media Use:** V2 media OS core (resolve cascade, providers, local generation, telemetry) + retire hyperframes-media ([5fe957363](https://github.com/heygen-com/hyperframes/commit/5fe957363d2be6d04f4a002fea965dbcc2bd633b))
|
||||
- **Studio:** Non-destructive crop + cross-project asset view ([676f461f6](https://github.com/heygen-com/hyperframes/commit/676f461f6a16f29fffe4633dcab1bec9366bddab))
|
||||
- **CLI:** Parakeet ASR engine for transcribe (--engine) + HYPERFRAMES_PYTHON override ([8c3590a90](https://github.com/heygen-com/hyperframes/commit/8c3590a90e4be76593ef7554a805004af789b373))
|
||||
- **CLI:** Forward feedback submissions to the backend feedback endpoint ([9fe06f30d](https://github.com/heygen-com/hyperframes/commit/9fe06f30da6fbe6085c616ee22c3627fb9e99f57), [#2003](https://github.com/heygen-com/hyperframes/pull/2003))
|
||||
|
||||
## Fixes
|
||||
|
||||
- Align README skills table padding so oxfmt --check passes ([68a8547f4](https://github.com/heygen-com/hyperframes/commit/68a8547f47dedb4e033f2bcc29449ca8b08b5afa), [#2014](https://github.com/heygen-com/hyperframes/pull/2014))
|
||||
- **Studio:** Remove selection overlay fill ([8f022d06e](https://github.com/heygen-com/hyperframes/commit/8f022d06ecfc170f36c5efbc078f57d7e6c0bcaa))
|
||||
- **Producer:** Mix muted browser media as silent for preview-render parity ([a2a80d5a5](https://github.com/heygen-com/hyperframes/commit/a2a80d5a5c8649a0ef79b89047ebf3debe42dfd1), [#1969](https://github.com/heygen-com/hyperframes/pull/1969))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.38...v0.7.39).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.38"
|
||||
description="Released - 2026-07-07"
|
||||
tags={["Release", "Engine,producer,cli", "CLI", "Studio"]}
|
||||
>
|
||||
drawElement fast capture is now **on by default** for macOS hardware-GPU renders — roughly 2× faster capture, wrapped in a per-render self-verification net that automatically falls back to the classic screenshot path if a frame can't be proven correct (kill switch: `PRODUCER_EXPERIMENTAL_FAST_CAPTURE=false`). Also new: media color grading (Studio inspector + runtime shaders), end-to-end render cancel, and richer render telemetry.
|
||||
|
||||
## Features
|
||||
|
||||
- **Engine,producer,cli:** DrawElement release telemetry on render_complete ([100570344](https://github.com/heygen-com/hyperframes/commit/1005703441c122e5462f34e0212e52e5490407a0), [#2002](https://github.com/heygen-com/hyperframes/pull/2002))
|
||||
- **Studio,studio Server,cli:** Render cancel end-to-end + renders/nle/storyboard UX ([241f9d683](https://github.com/heygen-com/hyperframes/commit/241f9d683ec7f34c9c398cc2a6c2d272a6e38268), [#1963](https://github.com/heygen-com/hyperframes/pull/1963))
|
||||
- **Engine,producer:** DrawElement fast-capture default-on with runtime self-verification safety net ([ec06f4bf8](https://github.com/heygen-com/hyperframes/commit/ec06f4bf895d811b3b1cf140e53e79244bbd1f9a), [#1998](https://github.com/heygen-com/hyperframes/pull/1998))
|
||||
- **Lint,player:** Fast-capture lint rule + player media sync ([992a9b660](https://github.com/heygen-com/hyperframes/commit/992a9b660708e3c4ca17620fb01265db95aaca59), [#1921](https://github.com/heygen-com/hyperframes/pull/1921))
|
||||
- **Producer:** Fast-capture render stages + remote bg-image localizer ([1d0dbcd3b](https://github.com/heygen-com/hyperframes/commit/1d0dbcd3b2a81ce00ca2deef5d6cc44f3b22ef96), [#1920](https://github.com/heygen-com/hyperframes/pull/1920))
|
||||
- **Engine:** Frame-capture core — fast-capture routing, worker-encode, dedup extension ([0e58344dc](https://github.com/heygen-com/hyperframes/commit/0e58344dca3052ca1e83337d22c54b977852f5b3), [#1919](https://github.com/heygen-com/hyperframes/pull/1919))
|
||||
- **Engine:** 3D projection + compositor-effect risk gate ([4749fe571](https://github.com/heygen-com/hyperframes/commit/4749fe5716357b4c1521d6a1644ab0be013976c1), [#1918](https://github.com/heygen-com/hyperframes/pull/1918))
|
||||
- **Engine:** DrawElementImage capture service ([795934adb](https://github.com/heygen-com/hyperframes/commit/795934adbac849cba6ec0b0ea83dc69b509a4f4c), [#1917](https://github.com/heygen-com/hyperframes/pull/1917))
|
||||
- **Studio:** Harden ui primitives — focus rings, keyboard tooltips, dialog hook ([cd1adcb58](https://github.com/heygen-com/hyperframes/commit/cd1adcb581d4638433b4f8c4d43986b419256762), [#1962](https://github.com/heygen-com/hyperframes/pull/1962))
|
||||
- **Engine,cli:** DrawElement fast-capture config + CLI flag ([e04f6dda3](https://github.com/heygen-com/hyperframes/commit/e04f6dda37bf7dd2fe5bf564ecac55713869d2d5), [#1916](https://github.com/heygen-com/hyperframes/pull/1916))
|
||||
- **Lint:** Warn when \<html dir\> can blank render output ([afa7f292f](https://github.com/heygen-com/hyperframes/commit/afa7f292fb267bb6498bf58afba18369574eee92), [#1893](https://github.com/heygen-com/hyperframes/pull/1893))
|
||||
- **CLI:** Emit sign-in lifecycle telemetry ([769dc702c](https://github.com/heygen-com/hyperframes/commit/769dc702c1d62b86f1f5b2a05d76af39fc1be6b2), [#2000](https://github.com/heygen-com/hyperframes/pull/2000))
|
||||
- **Studio:** Add color grading inspector ([139cf568b](https://github.com/heygen-com/hyperframes/commit/139cf568b481cd50a7d9d80ac2c00b6ec1a6410b))
|
||||
- **Studio Server:** Add media processing routes ([a3bf7eb99](https://github.com/heygen-com/hyperframes/commit/a3bf7eb995ae1b6cc6f6c95a999d19e6df3526b6))
|
||||
- **Runtime:** Render media color grading shaders ([870964b0c](https://github.com/heygen-com/hyperframes/commit/870964b0cf190702c76066aaef7c81ea4424f7ab))
|
||||
- **Core:** Add media color grading contract ([7c1443849](https://github.com/heygen-com/hyperframes/commit/7c144384962e6f4ce55046884055f6f462d044cf))
|
||||
- **CLI:** Figma import telemetry — subcommand labels, typed error codes, figma_import event ([e9076324e](https://github.com/heygen-com/hyperframes/commit/e9076324e703fdd91142b7f138b2f2c96cba1376), [#1979](https://github.com/heygen-com/hyperframes/pull/1979))
|
||||
- **Cli/telemetry:** Surface unrecognized agents in the agent_runtime=null bucket ([232d59147](https://github.com/heygen-com/hyperframes/commit/232d591479448233a9a54097da0963fc1034e450), [#1978](https://github.com/heygen-com/hyperframes/pull/1978))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **CLI:** Localize remote assets before validate so it matches render ([906c8d04f](https://github.com/heygen-com/hyperframes/commit/906c8d04f85b909252753b24102cdbad1780378d), [#2001](https://github.com/heygen-com/hyperframes/pull/2001))
|
||||
- **Studio:** Shell UX — data-loss guards, error surfacing, dialog contracts, toasts ([89e36da13](https://github.com/heygen-com/hyperframes/commit/89e36da13dcd5cda4455fa48e9cbb4d9b7f7dbda), [#1964](https://github.com/heygen-com/hyperframes/pull/1964))
|
||||
- **Compiler:** Don't scan media tags inside comments/scripts; DOM-check auto-start (#1938) ([1a3330a97](https://github.com/heygen-com/hyperframes/commit/1a3330a972d98de09a93e4cf6d1a61fcc1a63cb1), [#1940](https://github.com/heygen-com/hyperframes/pull/1940))
|
||||
- **Studio:** Share safe media path cleanup ([04f96da16](https://github.com/heygen-com/hyperframes/commit/04f96da16967eecff82dfb16e2e0cd238469ffb5))
|
||||
- **Studio:** Avoid regex in media asset path cleanup ([e0090efbf](https://github.com/heygen-com/hyperframes/commit/e0090efbf6946d8b28e54159e95dbd8b0a82484e))
|
||||
- **Studio:** Split color grading inspector files ([30a944de3](https://github.com/heygen-com/hyperframes/commit/30a944de342de0ff7715946a7d80c6f96d9e9074))
|
||||
- **Studio Server:** Share background removal job runner ([413ee07da](https://github.com/heygen-com/hyperframes/commit/413ee07da51c76a1430a58f40fb81021f1adb05c))
|
||||
- **Runtime:** Satisfy color grading audit ([644ae384a](https://github.com/heygen-com/hyperframes/commit/644ae384a549cea6a686c07344e99b87366c0480))
|
||||
- **Parsers,sdk,studio Server,studio:** Unify hf-id space across preview, disk, and SDK session ([3a717fa71](https://github.com/heygen-com/hyperframes/commit/3a717fa71997477c6f81dce7c9f2e63a4ad675ed), [#1981](https://github.com/heygen-com/hyperframes/pull/1981))
|
||||
|
||||
## Performance
|
||||
|
||||
- **Engine,producer:** Batch N drawElement frames per CDP round-trip (HF_DE_BATCH) ([d5ecb013d](https://github.com/heygen-com/hyperframes/commit/d5ecb013d71763891b8092c4bc081bb12b3d4782), [#1928](https://github.com/heygen-com/hyperframes/pull/1928))
|
||||
|
||||
## Docs & Examples
|
||||
|
||||
- **Figma:** Storyboard blurb reworded + frames-are-app-states escalation ([ccc130883](https://github.com/heygen-com/hyperframes/commit/ccc1308839a73e3a7d76fff4669a9a220b7eff11), [#2004](https://github.com/heygen-com/hyperframes/pull/2004))
|
||||
- Document Studio Color Grading ([b38c054ba](https://github.com/heygen-com/hyperframes/commit/b38c054baf97b90770f64310785fb514b0051de4))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.37...v0.7.38).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.37"
|
||||
description="Released - 2026-07-06"
|
||||
tags={["Release", "Lambda", "Sdk,studio", "Hyperframes Media"]}
|
||||
>
|
||||
A maintenance release with two reliability fixes: animation ids now resolve from the parsed script in the SDK and Studio (so tweens whose selectors don't currently match a live DOM element are handled correctly), and the CLI gives a clear error when `npx` can't be resolved on Windows. Also includes 53 documentation accuracy fixes.
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Sdk,studio:** Resolve animation ids from parsed script, not just DOM-matched elements ([c5d725abd](https://github.com/heygen-com/hyperframes/commit/c5d725abd0407761dc4a44de95417688ca6a4af2), [#1957](https://github.com/heygen-com/hyperframes/pull/1957))
|
||||
- **Hyperframes Media:** Surface a clear error when npx can't be resolved on Windows ([dfa6fedbc](https://github.com/heygen-com/hyperframes/commit/dfa6fedbcdaf8478b4be99d907d88fd079d9bc0a), [#1961](https://github.com/heygen-com/hyperframes/pull/1961))
|
||||
|
||||
## Docs & Examples
|
||||
|
||||
- Fix 53 inaccuracies across all documentation ([52d586dd3](https://github.com/heygen-com/hyperframes/commit/52d586dd31cfcc2d436d046f71907c2655617552))
|
||||
- Update README.md ([c2862eae1](https://github.com/heygen-com/hyperframes/commit/c2862eae1bc03fad7ccfb8b44be072757051c94d))
|
||||
|
||||
## Internal
|
||||
|
||||
- **Lambda:** Behavioral guard for handler CJS banner shims ([2b1109db4](https://github.com/heygen-com/hyperframes/commit/2b1109db4c05d8edfa71c398d8482e166f63b650), [#1977](https://github.com/heygen-com/hyperframes/pull/1977))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.36...v0.7.37).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.36"
|
||||
description="Released - 2026-07-05"
|
||||
tags={["Release", "CLI"]}
|
||||
>
|
||||
This release closes the remaining Windows FFmpeg path gap by making CLI preflight
|
||||
choose real `ffmpeg.exe`/`ffprobe.exe` binaries over shell shims. `hyperframes
|
||||
render` now avoids exporting `ffmpeg.cmd` as `HYPERFRAMES_FFMPEG_PATH`, keeping
|
||||
the engine on the hardened executable-resolution path.
|
||||
|
||||
## Fixes
|
||||
|
||||
- **CLI:** Prefer real ffmpeg exe over cmd shim ([98b539df](https://github.com/heygen-com/hyperframes/commit/98b539df728f221de677e7ede3e9e3eb4cad2bca), [#1958](https://github.com/heygen-com/hyperframes/pull/1958))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.35...v0.7.36).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.35"
|
||||
description="Released - 2026-07-05"
|
||||
tags={["Release", "Lambda", "Engine", "Hyperframes Media"]}
|
||||
>
|
||||
This release fixes three feedback-driven reliability issues: Lambda render bundles now provide CommonJS path globals for inlined dependencies, Windows FFmpeg lookup prefers real executables over command shims, and offline SFX resolution no longer records missing bundled files as usable audio.
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Lambda:** Define `__dirname` and `__filename` in handler bundles ([f999b40d](https://github.com/heygen-com/hyperframes/commit/f999b40d73aa8e0cbe082c199a566f544fbf6f96))
|
||||
- **Engine:** Prefer real FFmpeg executables over Windows command shims ([b7dcb9e2](https://github.com/heygen-com/hyperframes/commit/b7dcb9e2a3bd220c889be0ac8ff31b0ea5c62a36))
|
||||
- **Hyperframes Media:** Skip and report offline SFX entries whose bundled file is missing ([b34e623b](https://github.com/heygen-com/hyperframes/commit/b34e623bf8c5fb2ae9608edf1908c466584d627e))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.34...v0.7.35).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.34"
|
||||
description="Released - 2026-07-05"
|
||||
tags={["Release", "Engine"]}
|
||||
>
|
||||
This release hardens DOM layer masking in the engine so temporary screenshot
|
||||
visibility changes restore the exact prior inline state. It adds another guard
|
||||
around layered HDR captures, reducing the chance that masking one frame affects
|
||||
later captures.
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Engine:** Preserve DOM mask visibility state ([56d4a7032](https://github.com/heygen-com/hyperframes/commit/56d4a7032b64ecc3f46eeb7ed17e62a11af1c07b), [#1953](https://github.com/heygen-com/hyperframes/pull/1953))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.33...v0.7.34).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.33"
|
||||
description="Released - 2026-07-04"
|
||||
tags={["Release", "Producer"]}
|
||||
>
|
||||
This release fixes an HDR compositing regression where hidden or future timed DOM
|
||||
descendants could be revived during per-layer screenshots and bleed into HDR
|
||||
frames. DOM-layer masking now preserves those hidden descendants while capturing
|
||||
the active layer, keeping HDR output aligned with preview timing.
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Producer:** Avoid reviving hidden DOM in HDR layers ([bb066077b](https://github.com/heygen-com/hyperframes/commit/bb066077b4ecbf4efc021779e37da6475c6f80dd), [#1935](https://github.com/heygen-com/hyperframes/pull/1935))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.32...v0.7.33).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.32"
|
||||
description="Released - 2026-07-04"
|
||||
tags={["Release", "Hyperframes Media", "CLI", "Video Workflows"]}
|
||||
>
|
||||
This release hardens the CLI validation and browser-install paths: validate now seeks through the runtime player, honors the configured timeout more consistently, and browser installs recover from stale or partial downloads without racing concurrent forced reinstalls. It also closes Windows media setup gaps, scopes `window.__hyperframes` correctly inside sub-compositions, pads transition-tail frame durations, and moves large audio filter graphs out of the command line.
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Hyperframes Media:** Install MusicGen deps via python3 -m pip, not bare pip ([942080fa8](https://github.com/heygen-com/hyperframes/commit/942080fa8e8e77de93ef6546690b5b5421d80e88), [#1894](https://github.com/heygen-com/hyperframes/pull/1894))
|
||||
- **Hyperframes Media:** Resolve python3 -\> python/py on Windows ([3388635f1](https://github.com/heygen-com/hyperframes/commit/3388635f1d1431de0b2a2e7d132f834f7614557c), [#1922](https://github.com/heygen-com/hyperframes/pull/1922))
|
||||
- **CLI:** Validate seeks the runtime player directly, not raw timelines ([3f49f107e](https://github.com/heygen-com/hyperframes/commit/3f49f107eb8fb64b34f621bfcd076391ff06e167), [#1895](https://github.com/heygen-com/hyperframes/pull/1895))
|
||||
- **CLI:** Validate navigation timeout honors --timeout, hints on CDN scripts ([0338be97f](https://github.com/heygen-com/hyperframes/commit/0338be97fdc548fd4a179561810c0c9320b61388), [#1929](https://github.com/heygen-com/hyperframes/pull/1929))
|
||||
- **Video Workflows:** Pad the frame's own duration to match the transition tail ([8bc1e5d60](https://github.com/heygen-com/hyperframes/commit/8bc1e5d603bd828511bdc6dddaa96a64ea7a43d3), [#1889](https://github.com/heygen-com/hyperframes/pull/1889))
|
||||
- **Engine:** Write the audio mix filter graph to a file, not the command line ([8a3227f54](https://github.com/heygen-com/hyperframes/commit/8a3227f5482320907edcb47972c3d7f6e25c6d62), [#1890](https://github.com/heygen-com/hyperframes/pull/1890))
|
||||
- **CLI:** Purge stale/partial browser installs instead of wedging retries ([78069da14](https://github.com/heygen-com/hyperframes/commit/78069da1403669cb3f68a475da128d0b0ce14a97), [#1913](https://github.com/heygen-com/hyperframes/pull/1913))
|
||||
- **Core:** Route window.__hyperframes to the scoped variant in sub-comps ([f7c9c35d8](https://github.com/heygen-com/hyperframes/commit/f7c9c35d8b1095037402cc55539084d514691b28), [#1931](https://github.com/heygen-com/hyperframes/pull/1931))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.31...v0.7.32).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.31"
|
||||
description="Released - 2026-07-04"
|
||||
tags={["Release", "Core,cli"]}
|
||||
>
|
||||
This release makes Figma imports first-class citizens of the shared media inventory: every `hyperframes figma asset` import now regenerates `.media/index.md` (byte-identical with media-use's format), carries `--description`/`--entity` metadata into the manifest, index, and alt text, and the reuse path correctly upserts metadata and dedupes against any prior format/scale/version tuple. media-use's entity lookups now match figma-imported brand marks across the icon/image boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- **Core,cli:** Media-use interop — shared index.md regen + description/entity on figma imports ([3900caaaa](https://github.com/heygen-com/hyperframes/commit/3900caaaa94443efb01597dac26df1533f8add35), [#1927](https://github.com/heygen-com/hyperframes/pull/1927))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.30...v0.7.31).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.30"
|
||||
description="Released - 2026-07-04"
|
||||
tags={["Release", "Studio", "Sdk"]}
|
||||
>
|
||||
This release adds a low-volume attempt counter to the resolver-shadow tripwire, giving the SDK-cutover soak gate a real `divergences / attempts` denominator instead of a bare divergence count. It also fixes `moveElement` losing its offset when the target element is under a running GSAP animation.
|
||||
|
||||
## Features
|
||||
|
||||
- **Studio:** Add resolver-shadow attempt counter for soak-gate denominator ([fe821ff8b](https://github.com/heygen-com/hyperframes/commit/fe821ff8bc9acbb840b4a42153db66790b9419a1), [#1926](https://github.com/heygen-com/hyperframes/pull/1926))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Sdk:** MoveElement survives GSAP animation per-axis via runtime delta translate ([74faa4b2a](https://github.com/heygen-com/hyperframes/commit/74faa4b2a42c60c77d1550e7288bf3333c15e25d), [#1875](https://github.com/heygen-com/hyperframes/pull/1875))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.29...v0.7.30).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.29"
|
||||
description="Released - 2026-07-04"
|
||||
tags={["Release", "Skills", "Studio", "Core,cli"]}
|
||||
>
|
||||
This release ships the Figma integration: import Figma frames as frozen assets, pull brand variables and styles as composition tokens, convert frames to editable brand-linked HTML components, and translate Figma Motion timelines into seekable GSAP — all routed through the new /figma skill and `hyperframes figma` CLI. It also lands a wave of engine extraction-performance work (caching, dedup, one-pass VFR/HDR) and studio persist-failure hardening.
|
||||
|
||||
## Features
|
||||
|
||||
- **Skills:** Reroute /figma by capability - REST/CLI for phases 1-3, MCP for 4-5 (M4) ([566d49382](https://github.com/heygen-com/hyperframes/commit/566d49382c9cb5c0f690e29f3cd1014b8e69e39a), [#1873](https://github.com/heygen-com/hyperframes/pull/1873))
|
||||
- **Core,cli:** Figma component import with binding-aware node-to-html mapper (M3) ([ee7a96147](https://github.com/heygen-com/hyperframes/commit/ee7a96147a5d71797ef041e48395ed1918a375a9), [#1872](https://github.com/heygen-com/hyperframes/pull/1872))
|
||||
- **Core,cli:** Figma tokens import with alias-aware binding records (M2) ([4d60792ad](https://github.com/heygen-com/hyperframes/commit/4d60792adc0cc9c807f5734b492052a8598d6d4e), [#1871](https://github.com/heygen-com/hyperframes/pull/1871))
|
||||
- **Core,cli:** Figma REST client, asset import command, binding index (M0+M1) ([fb13797d2](https://github.com/heygen-com/hyperframes/commit/fb13797d2f2715dc38649add1aea577d214d42f8), [#1870](https://github.com/heygen-com/hyperframes/pull/1870))
|
||||
- **Core:** Figma motion → GSAP translator + /figma skill v1 ([e92700acd](https://github.com/heygen-com/hyperframes/commit/e92700acded50469d3fb35da3a8b198d2cdc36d8), [#1869](https://github.com/heygen-com/hyperframes/pull/1869))
|
||||
- **Core:** Figma module foundations — types, parseFigmaRef, freeze, manifest, asset snippet ([397c3ba7a](https://github.com/heygen-com/hyperframes/commit/397c3ba7a84211bfa52a93a410f34368f7ab5110), [#1868](https://github.com/heygen-com/hyperframes/pull/1868))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Studio:** Surface persist failures with toast and guarded revert ([e6e0d97cc](https://github.com/heygen-com/hyperframes/commit/e6e0d97cc560ebca57a28f5c0872e706679f568d), [#1910](https://github.com/heygen-com/hyperframes/pull/1910))
|
||||
- **Studio:** Per-child patch op builders and persist-seam harness ([22942280b](https://github.com/heygen-com/hyperframes/commit/22942280b61c3b6d40dae6ca00a401969684e234), [#1909](https://github.com/heygen-com/hyperframes/pull/1909))
|
||||
- **Studio Server:** Child-scoped patch operations with batch abort ([23f67da5e](https://github.com/heygen-com/hyperframes/commit/23f67da5e236fa9b5c5838786b48378e7704b67d), [#1908](https://github.com/heygen-com/hyperframes/pull/1908))
|
||||
- **Studio:** Make canvas selection hit intended elements ([02e9d6142](https://github.com/heygen-com/hyperframes/commit/02e9d6142d8e3d812c1d8525753355793a3b9988), [#1907](https://github.com/heygen-com/hyperframes/pull/1907))
|
||||
|
||||
## Performance
|
||||
|
||||
- **Engine:** Superset extraction for overlapping trims of one source ([1a7002f20](https://github.com/heygen-com/hyperframes/commit/1a7002f208353239f0e10596fa1decc897fed34f), [#1885](https://github.com/heygen-com/hyperframes/pull/1885))
|
||||
- **Engine:** One-pass SDR-to-HDR extraction with cache-key transform ([48f158a0c](https://github.com/heygen-com/hyperframes/commit/48f158a0c2cd27bfc114903a37560cd4db872fe8), [#1902](https://github.com/heygen-com/hyperframes/pull/1902))
|
||||
- **Engine:** Extraction cache on by default with atomic publish and LRU gc ([34590649a](https://github.com/heygen-com/hyperframes/commit/34590649a05594914b98d4cc6198a2e5aac953c6), [#1901](https://github.com/heygen-com/hyperframes/pull/1901))
|
||||
- **Engine:** Dedupe identical extractions within one render ([8d64d48e4](https://github.com/heygen-com/hyperframes/commit/8d64d48e4a9ae620771e331d83bd74eb1716d66b), [#1900](https://github.com/heygen-com/hyperframes/pull/1900))
|
||||
- **Engine:** One-pass VFR extraction with -fps_mode cfr ([786058334](https://github.com/heygen-com/hyperframes/commit/78605833415e9f80097a773b949c146f4e1a5e9d), [#1899](https://github.com/heygen-com/hyperframes/pull/1899))
|
||||
- **Engine:** Write PNG frames at compression_level 1 ([557a27027](https://github.com/heygen-com/hyperframes/commit/557a270271372d458d5adb944473a485d47e3079), [#1898](https://github.com/heygen-com/hyperframes/pull/1898))
|
||||
|
||||
## Internal
|
||||
|
||||
- **Studio:** Agent-browser e2e smoke for the design panel ([08c0a0351](https://github.com/heygen-com/hyperframes/commit/08c0a03510aaa86f1acc848ab75d0b24557dbf7e), [#1912](https://github.com/heygen-com/hyperframes/pull/1912))
|
||||
- **Studio:** Cover persist-failure hook behavior ([64688ddaf](https://github.com/heygen-com/hyperframes/commit/64688ddaf2e5bb17d203b0d28f02e3142ac0035c), [#1911](https://github.com/heygen-com/hyperframes/pull/1911))
|
||||
- **Studio:** Add design-panel QA fixture and triage matrix ([4d199c0f3](https://github.com/heygen-com/hyperframes/commit/4d199c0f3ab8bec55e32eff197e12eca796bf172), [#1906](https://github.com/heygen-com/hyperframes/pull/1906))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.28...v0.7.29).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.28"
|
||||
description="Released - 2026-07-03"
|
||||
tags={["Release", "Engine", "Producer"]}
|
||||
>
|
||||
This release fixes a render/preview mismatch where sub-composition root styles could diverge in producer renders. It also scales static-frame dedup verification with longer runs so repeated-frame optimizations stay reliable across longer outputs.
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Engine:** Scale static-dedup verification density with run length ([df221c1f](https://github.com/heygen-com/hyperframes/commit/df221c1fd685862b62e3057438203f79eedae111), [#1903](https://github.com/heygen-com/hyperframes/pull/1903))
|
||||
- **Producer:** Render diverges from preview for sub-composition root styling ([7e8a1466](https://github.com/heygen-com/hyperframes/commit/7e8a1466c3b058071c5ed89241ed70e2d0878645), [#1886](https://github.com/heygen-com/hyperframes/pull/1886))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.27...v0.7.28).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.27"
|
||||
description="Released - 2026-07-03"
|
||||
tags={["Release", "Capture", "Hyperframes Media", "Producer"]}
|
||||
>
|
||||
This release adds three new capture-extraction categories (gradient washes, glass panels, nav CTAs) and migrates cloud-render uploads to the higher-capacity direct-uploads endpoint. Also includes reliability fixes across CLI (Chrome install races, media validation), producer (audio-mix failures now surface instead of shipping silently video-only), lint (several false-positive fixes), and slideshow presenter mode over screen share.
|
||||
|
||||
## Features
|
||||
|
||||
- **Capture:** Extract gradient washes, glass panels, and nav CTAs ([dd774b369](https://github.com/heygen-com/hyperframes/commit/dd774b3692e71656b038cb2d5a11f05ba52c2880), [#1879](https://github.com/heygen-com/hyperframes/pull/1879))
|
||||
- **CLI:** Migrate cloud-render upload to /v3/assets/direct-uploads (200MB) ([a59ff0d91](https://github.com/heygen-com/hyperframes/commit/a59ff0d91b4074bd16b7e4d41fb9a9ffbc9eab8e), [#1844](https://github.com/heygen-com/hyperframes/pull/1844))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Hyperframes Media:** Npx spawn without shell:true fails silently on Windows ([3b6791824](https://github.com/heygen-com/hyperframes/commit/3b67918242ab0d74b37241b059fa54a792977067), [#1845](https://github.com/heygen-com/hyperframes/pull/1845))
|
||||
- **Hyperframes Media:** Fall back to ffmpeg when ffprobe is missing ([e2b43583f](https://github.com/heygen-com/hyperframes/commit/e2b43583fc2480aa9511cd5e606a58841c4f211e), [#1877](https://github.com/heygen-com/hyperframes/pull/1877))
|
||||
- **Hyperframes Media:** Cap TTS synthesis concurrency instead of firing every line at once ([2e9a33ca7](https://github.com/heygen-com/hyperframes/commit/2e9a33ca719a2b28bd8bb51083df32b8f74ca437), [#1862](https://github.com/heygen-com/hyperframes/pull/1862))
|
||||
- **Producer:** Surface the reason when audio mixing fails instead of silently shipping video-only ([f40dbd86c](https://github.com/heygen-com/hyperframes/commit/f40dbd86cf2df77a26004578a66b4035e4d742c5), [#1854](https://github.com/heygen-com/hyperframes/pull/1854))
|
||||
- **CLI:** Lock chrome-headless-shell install against concurrent extraction races ([638c33bc0](https://github.com/heygen-com/hyperframes/commit/638c33bc01965247614af67875644e2e18a8e4a3), [#1866](https://github.com/heygen-com/hyperframes/pull/1866))
|
||||
- **CLI:** Validate stops misreporting slow-loading media as unreadable ([b087f1e3c](https://github.com/heygen-com/hyperframes/commit/b087f1e3c09d24e3a339ec4bf2f129af39f0a915), [#1849](https://github.com/heygen-com/hyperframes/pull/1849))
|
||||
- **Engine:** Name the fix in the ffmpeg encode-timeout error message ([eef469075](https://github.com/heygen-com/hyperframes/commit/eef469075299439c0c79b38d8488639106a42dca), [#1858](https://github.com/heygen-com/hyperframes/pull/1858))
|
||||
- **Lint:** Stop scene-exit hard-kill rules from contradicting gsap_animates_clip_element ([c7b34d5c6](https://github.com/heygen-com/hyperframes/commit/c7b34d5c6536129e5589377e7e4c9a0b763b4494), [#1846](https://github.com/heygen-com/hyperframes/pull/1846))
|
||||
- **Lint:** Stop a leading \<svg\> defs block from being mistaken for the composition root ([8ee4b7dfd](https://github.com/heygen-com/hyperframes/commit/8ee4b7dfda8f884076f4844136611c886d8e8107), [#1867](https://github.com/heygen-com/hyperframes/pull/1867))
|
||||
- **Lint:** Recognize computed-key window.__timelines registrations ([d2f1adc2a](https://github.com/heygen-com/hyperframes/commit/d2f1adc2affea69e33550d60945a237e92602eb5), [#1874](https://github.com/heygen-com/hyperframes/pull/1874))
|
||||
- **Lint:** Mention src: local() for system fonts in font_family_without_font_face ([b7eb0dfb5](https://github.com/heygen-com/hyperframes/commit/b7eb0dfb5a87d53bb542f5f70723110ea60c6990), [#1859](https://github.com/heygen-com/hyperframes/pull/1859))
|
||||
- **Slideshow:** Make presenter mode work over Google Meet/Zoom screen share ([a7c3cc7d6](https://github.com/heygen-com/hyperframes/commit/a7c3cc7d689b13c5bb96559f9bad713205edb194))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.26...v0.7.27).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.26"
|
||||
description="Released - 2026-07-02"
|
||||
tags={["Release", "Producer", "CLI", "Studio"]}
|
||||
>
|
||||
v0.7.26 makes imported designs more reliable by normalizing host-system primary font stacks to deterministic web fonts before distributed plan validation. It also adds render-reliability telemetry for preflight rejections, transient retries, and OOM classification, plus Studio controls and fixes around automatic keyframe recording.
|
||||
|
||||
## Features
|
||||
|
||||
- **CLI:** Emit render_preflight_rejected telemetry for P1-3 pre-flight saves ([9b41891f](https://github.com/heygen-com/hyperframes/commit/9b41891f3a39e73a0b91bd97bb00fa6767a4769e), [#1856](https://github.com/heygen-com/hyperframes/pull/1856))
|
||||
- **Producer,cli:** Render-reliability telemetry counters for capture hardening ([733f88cb](https://github.com/heygen-com/hyperframes/commit/733f88cb1f10bdbe31c924c4e0f1f8359f19e7d9), [#1850](https://github.com/heygen-com/hyperframes/pull/1850))
|
||||
- **Studio:** Add a global toggle to stop manual edits from auto-recording keyframes ([636d5dd6](https://github.com/heygen-com/hyperframes/commit/636d5dd6c52083a6c10a25eb00580c207b164534))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Producer:** Normalize system-primary font stacks ([e10e61ce](https://github.com/heygen-com/hyperframes/commit/e10e61ceb2cfb9664915548b4b8d42456d636aa1), [#1857](https://github.com/heygen-com/hyperframes/pull/1857))
|
||||
- **Studio:** Don't crash resizing an element whose keyframes were removed ([dff3634e](https://github.com/heygen-com/hyperframes/commit/dff3634ea89d8834a41f2fd935111d9f5530a637))
|
||||
- **Studio:** Re-sync soft-reloaded timelines to the studio's own scrub time ([70606f84](https://github.com/heygen-com/hyperframes/commit/70606f840df92549afdc85824fdc58d319b93819))
|
||||
- **Studio:** Fix array-form keyframe writes, diamond click-deselect, and nested video sync ([1b8b2ac4](https://github.com/heygen-com/hyperframes/commit/1b8b2ac425703e4ea3600b6656f7fc67bb7cd7b2))
|
||||
- **Studio:** Resolve recent timeline regressions ([9b311588](https://github.com/heygen-com/hyperframes/commit/9b311588be7e72ab7627c55a001fa63ae5c8307c))
|
||||
|
||||
## Internal
|
||||
|
||||
- **CLI:** De-flake cold-import tests under CI contention via vitest timeouts ([438474c9](https://github.com/heygen-com/hyperframes/commit/438474c968e1c094cb3a01453d6309c099502666), [#1855](https://github.com/heygen-com/hyperframes/pull/1855))
|
||||
|
||||
## Other Changes
|
||||
|
||||
- Keep Codex plugin category as Creativity ([bf8b3d97](https://github.com/heygen-com/hyperframes/commit/bf8b3d9796a740ff93015d4723b486bfcc5935a1), [#1860](https://github.com/heygen-com/hyperframes/pull/1860))
|
||||
- **Studio:** Make the auto-keyframe toggle icon a diamond with a record dot ([ae50327b](https://github.com/heygen-com/hyperframes/commit/ae50327bdb4a80123ff3b099ff067310a59b85c3))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.25...v0.7.26).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.25"
|
||||
description="Released - 2026-07-02"
|
||||
tags={["Release", "CLI", "Engine", "Render"]}
|
||||
>
|
||||
v0.7.25 adds the `hyperframes keyframes` command for inspecting GSAP, CSS, Anime.js, and 3D onion-skin shots from the CLI. It also improves first-time render reliability with Linux/WSL dependency checks, actionable preset preflight failures, transient capture retries with OOM guidance, and isolated forced-screenshot workers for parallel renders.
|
||||
|
||||
## Features
|
||||
|
||||
- **CLI:** Keyframes command (surface GSAP/CSS/Anime keyframes + 3D onion-skin --shot) ([a908af11](https://github.com/heygen-com/hyperframes/commit/a908af11a814d6fd2c9d3b65d8311e5c3f3742b9), [#1603](https://github.com/heygen-com/hyperframes/pull/1603))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Engine:** Parallelize forced screenshot workers ([145c71e8](https://github.com/heygen-com/hyperframes/commit/145c71e837f208d382462f0dfe5d8840f3c33414), [#1848](https://github.com/heygen-com/hyperframes/pull/1848))
|
||||
- **Render:** Pre-flight aspect-ratio / alpha preset mismatch with actionable guidance ([6be46813](https://github.com/heygen-com/hyperframes/commit/6be46813a213cdd1ef605f2efd93c76bb18deda9), [#1843](https://github.com/heygen-com/hyperframes/pull/1843))
|
||||
- **Producer:** Harden capture against timeouts, transient tab deaths, and OOM ([c0c3abf0](https://github.com/heygen-com/hyperframes/commit/c0c3abf0f16ca41e5dfebcf6b70bd6845127130c), [#1842](https://github.com/heygen-com/hyperframes/pull/1842))
|
||||
- **CLI:** Detect missing Chrome libs & ffmpeg on Linux/WSL in doctor ([180f368a](https://github.com/heygen-com/hyperframes/commit/180f368af1dd6b4d949d182805644077feaa81bc), [#1841](https://github.com/heygen-com/hyperframes/pull/1841))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.24...v0.7.25).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.24"
|
||||
description="Released - 2026-07-01"
|
||||
tags={["Release", "Studio"]}
|
||||
>
|
||||
Follow-up to v0.7.23's resolver-shadow filter extension: single-flights the reorder source read (one fetch per batch instead of one per unresolved target) and tags loose-match `sourceHfIdCount: 0` events so they're distinguishable on the telemetry dashboard.
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Studio:** Single-flight reorder source read, tag loose-match sourceHfIdCount, brace style ([69b927a30](https://github.com/heygen-com/hyperframes/commit/69b927a300c9c9f8f74cd9cc55dff98abc0e5974))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.23...v0.7.24).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.23"
|
||||
description="Released - 2026-07-01"
|
||||
tags={["Release", "Studio", "Player", "Runtime"]}
|
||||
>
|
||||
This release unifies CLI and Studio telemetry identity, extends the resolver-shadow noise filter to cover timing/GSAP/reorder edits, and auto-infers composition duration for CSS/WAAPI/Lottie compositions so `data-duration` is no longer required. Also includes pre-flight validation for malformed sub-compositions and a batch of CLI/lint reliability fixes.
|
||||
|
||||
## Features
|
||||
|
||||
- **Telemetry:** Unify CLI and Studio PostHog identity (Layer 1) ([9c4d9e50a](https://github.com/heygen-com/hyperframes/commit/9c4d9e50a01e0e139d95a80f6c2216867036d905), [#1829](https://github.com/heygen-com/hyperframes/pull/1829))
|
||||
- **Capture:** Extract chips/stat-cells/tabs, detect icon fonts, transparent grounds ([6cc87312d](https://github.com/heygen-com/hyperframes/commit/6cc87312d4dffba9df531f6f156c0d0d1462aa82))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Studio:** Extend resolver-shadow runtime-node filter to timing/GSAP/reorder chokepoints ([c215a20c8](https://github.com/heygen-com/hyperframes/commit/c215a20c8e43c1966e92b42eddb45534922a6fae), [#1839](https://github.com/heygen-com/hyperframes/pull/1839))
|
||||
- **Player:** Rescale on cross-origin timeline ready, guard warn spam ([e5ec4cf53](https://github.com/heygen-com/hyperframes/commit/e5ec4cf53243a19a8a34f029c3139abaf07315c3), [#1840](https://github.com/heygen-com/hyperframes/pull/1840))
|
||||
- **Runtime:** Auto-infer composition duration for CSS/WAAPI/Lottie so data-duration is optional ([24edb1509](https://github.com/heygen-com/hyperframes/commit/24edb15095d2343fcd20c0468d2591b075810507), [#1830](https://github.com/heygen-com/hyperframes/pull/1830))
|
||||
- **Core,producer,cli:** Pre-flight validation for empty/malformed sub-compositions ([cf573f7f3](https://github.com/heygen-com/hyperframes/commit/cf573f7f3f8bfcc68b99c514afba4de0655436fc), [#1831](https://github.com/heygen-com/hyperframes/pull/1831))
|
||||
- **Engine:** Retry probe on pollHfReady zero-duration timeout ([b33d54f54](https://github.com/heygen-com/hyperframes/commit/b33d54f54b362b754d0b32427e31ba6a8bf3f49b), [#1824](https://github.com/heygen-com/hyperframes/pull/1824))
|
||||
- **Skills:** Clear two Snyk Fails and harden the network + supply-chain surface ([535297280](https://github.com/heygen-com/hyperframes/commit/535297280aabdd55005682d4774cbde2c5f1ef4b), [#1804](https://github.com/heygen-com/hyperframes/pull/1804))
|
||||
- **Skills:** Make hyperframes package version resolution non-fatal and overridable ([060889f84](https://github.com/heygen-com/hyperframes/commit/060889f84c430ad8d8d90fe1b7fd74ef4485a705), [#1825](https://github.com/heygen-com/hyperframes/pull/1825))
|
||||
- **CLI:** Keep doctor resilient to a corrupt browser cache ([0d202ea77](https://github.com/heygen-com/hyperframes/commit/0d202ea77921e123c3df35133d4919bcf02a94e7), [#1822](https://github.com/heygen-com/hyperframes/pull/1822))
|
||||
- **CLI:** Exclude clip-path-hidden text from inspect layout and contrast audits ([a7d0ab2d6](https://github.com/heygen-com/hyperframes/commit/a7d0ab2d61a0c7b1f60a9782981d8852af046a78), [#1821](https://github.com/heygen-com/hyperframes/pull/1821))
|
||||
- **Lint:** Catch visible markup comments ([d1038918b](https://github.com/heygen-com/hyperframes/commit/d1038918bb92307d62ed7edfbb6c9b3ed37e41cb), [#1819](https://github.com/heygen-com/hyperframes/pull/1819))
|
||||
- **CLI:** Don't run unbounded ffprobe on remote snapshot inputs ([1a36b2abb](https://github.com/heygen-com/hyperframes/commit/1a36b2abb4298ac20e5c842ac2b06e0230ff5a5e))
|
||||
- **CLI:** Extract snapshot video frames from remote http(s) srcs ([1f377f732](https://github.com/heygen-com/hyperframes/commit/1f377f732b73a376b4371e9aa4347e7315e0a774))
|
||||
|
||||
## Docs & Examples
|
||||
|
||||
- Clarify transparent WebM shows as yuv420p under ffprobe (alpha is a VP9 side-channel) ([534d6177b](https://github.com/heygen-com/hyperframes/commit/534d6177b65bfd7aadc74d1a0632410e03b1d8ac), [#1823](https://github.com/heygen-com/hyperframes/pull/1823))
|
||||
- **Sdk:** Republish SDK tab pages (Mintlify incremental deploy skipped the new files) ([bd27ea31a](https://github.com/heygen-com/hyperframes/commit/bd27ea31adcee07736f6a2c244acda77a4215975))
|
||||
- **Sdk:** Pin editing-affordances availability to @hyperframes/sdk@0.7.22 ([9140ee51b](https://github.com/heygen-com/hyperframes/commit/9140ee51b3109b97346cda083714e0b13b1270cf))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.22...v0.7.23).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.22"
|
||||
description="Released - 2026-06-30"
|
||||
tags={["Release", "Sdk", "Editing", "CLI"]}
|
||||
>
|
||||
This release makes `@hyperframes/sdk` a first-class editing engine: a new shared `resolveEditingAffordances` API (with a browser SDK adapter) tells custom editors what's editable on any element, backed by comprehensive SDK reference docs and guides. The storyboard view is now on by default, plus encode, lint, and CLI reliability fixes.
|
||||
|
||||
## Features
|
||||
|
||||
- **Editing:** Shared resolveEditingAffordances (core) + studio re-point + SDK adapter ([5915590b0](https://github.com/heygen-com/hyperframes/commit/5915590b069a2e3144ae1e1bfb49478b72240894), [#1814](https://github.com/heygen-com/hyperframes/pull/1814))
|
||||
- **CLI:** Add --public flag to publish ([856bb0980](https://github.com/heygen-com/hyperframes/commit/856bb0980fc9f85244cd1d0c8233cb7390642b0b), [#1815](https://github.com/heygen-com/hyperframes/pull/1815))
|
||||
- **CLI:** File a GitHub issue with a published repro from feedback ([010df6a0a](https://github.com/heygen-com/hyperframes/commit/010df6a0a4edbf7179d634fb12805f47ef89ec6e), [#1816](https://github.com/heygen-com/hyperframes/pull/1816))
|
||||
- **Studio:** Make storyboard view default available (remove FF) ([f24a1a9ce](https://github.com/heygen-com/hyperframes/commit/f24a1a9ce7250965df8e89ece4403d373c9d01e9), [#1794](https://github.com/heygen-com/hyperframes/pull/1794))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Lint:** Recognize Three.js loaded via ESM URL/path imports ([27a7f3749](https://github.com/heygen-com/hyperframes/commit/27a7f37494f9b3e315bb8b89196d71b6f541b31f), [#1805](https://github.com/heygen-com/hyperframes/pull/1805))
|
||||
- **Lint:** Stop font_family_without_font_face flagging system-ui stacks and var() ([11432ea8c](https://github.com/heygen-com/hyperframes/commit/11432ea8ce82571c8473244f56edc06a8decf531), [#1796](https://github.com/heygen-com/hyperframes/pull/1796))
|
||||
- **Engine:** Pad odd output dimensions up to even for H.264/H.265 encode ([3a2f05288](https://github.com/heygen-com/hyperframes/commit/3a2f0528890c0de4ac0bb31629323ed7f00133d9), [#1802](https://github.com/heygen-com/hyperframes/pull/1802))
|
||||
- **Examples:** Host the kinetic-type A-roll so the video actually renders ([c01d1ae03](https://github.com/heygen-com/hyperframes/commit/c01d1ae03d710022f7fbabd21991eba9faea1eb3), [#1799](https://github.com/heygen-com/hyperframes/pull/1799))
|
||||
- **Lint:** Stop overlapping_gsap_tweens flagging distinct unresolved targets ([036e3660c](https://github.com/heygen-com/hyperframes/commit/036e3660cd1bd15ded7300ef2623f26aa1179faa), [#1798](https://github.com/heygen-com/hyperframes/pull/1798))
|
||||
- **CLI:** Never print "[object Object]" from validate/inspect errors ([a5c2636e8](https://github.com/heygen-com/hyperframes/commit/a5c2636e8c35043e89d476300019e37dfe0cb24c), [#1810](https://github.com/heygen-com/hyperframes/pull/1810))
|
||||
- **CLI:** Skip AI skills install when git is unavailable ([23adfdc49](https://github.com/heygen-com/hyperframes/commit/23adfdc496f2187c8eb3632d0a69e053930680f5), [#1803](https://github.com/heygen-com/hyperframes/pull/1803))
|
||||
- **CLI:** Omit render duration when feedback command has none ([db61509dd](https://github.com/heygen-com/hyperframes/commit/db61509ddcdc9897768a9d242855956d21d5dd5b), [#1797](https://github.com/heygen-com/hyperframes/pull/1797))
|
||||
- **CLI:** Show output video length in render summary, not render time ([e7939ccd5](https://github.com/heygen-com/hyperframes/commit/e7939ccd5309f6c31045d4ebd11f1912e2bd969c), [#1812](https://github.com/heygen-com/hyperframes/pull/1812))
|
||||
- **CLI:** Serve project media with HTTP Range so validate reads WAV duration ([466ee08ff](https://github.com/heygen-com/hyperframes/commit/466ee08ffa540755a430bd54f078a3764ca4b4ef), [#1811](https://github.com/heygen-com/hyperframes/pull/1811))
|
||||
- Storyboard-angle review follow-ups (M1 bg-on-clip, B3 slideshow, parser guard, CLI fixes) ([a4303137c](https://github.com/heygen-com/hyperframes/commit/a4303137cbce5164f1663996b3c3b2f5921c5485), [#1791](https://github.com/heygen-com/hyperframes/pull/1791))
|
||||
- **Studio:** Filter runtime-generated nodes from resolver-shadow telemetry ([a4eacaec3](https://github.com/heygen-com/hyperframes/commit/a4eacaec37e79d8b3fcdbe514b59bf1e4035f4f8), [#1795](https://github.com/heygen-com/hyperframes/pull/1795))
|
||||
|
||||
## Docs & Examples
|
||||
|
||||
- **Sdk:** Comprehensive SDK reference + guides ([26e9283f6](https://github.com/heygen-com/hyperframes/commit/26e9283f6b7c660c0f76a3b82143a226cde70702), [#1817](https://github.com/heygen-com/hyperframes/pull/1817))
|
||||
- Root composition duration is compile-time, not script/--variables parameterizable ([bdd0084c2](https://github.com/heygen-com/hyperframes/commit/bdd0084c2a778b541b89af09689fd7a997408cdc), [#1818](https://github.com/heygen-com/hyperframes/pull/1818))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.21...v0.7.22).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.21"
|
||||
description="Released - 2026-06-30"
|
||||
tags={["Release", "Lint", "Skills", "Core"]}
|
||||
>
|
||||
Adds a lint rule that errors on `crossorigin` on media (it renders fine but shows blank in preview) and fixes the root cause of id-less media rendering as a blank wash in the timing compiler.
|
||||
|
||||
## Features
|
||||
|
||||
- **Lint:** Error on crossorigin on media (breaks preview) ([1faeba4a](https://github.com/heygen-com/hyperframes/commit/1faeba4aa5e309c1ea688e37e833ffaba60429eb), [#1793](https://github.com/heygen-com/hyperframes/pull/1793))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Core:** Root-cause id-less media wash in timingCompiler getAttr, drop the band-aid ([0dfedd11](https://github.com/heygen-com/hyperframes/commit/0dfedd111c49948c531f2e2b0b290e50024176e7), [#1792](https://github.com/heygen-com/hyperframes/pull/1792))
|
||||
|
||||
## Docs & Examples
|
||||
|
||||
- **Skills:** Instruct agents to run `hyperframes feedback` after render ([04700ea9](https://github.com/heygen-com/hyperframes/commit/04700ea9c51d6b29f8512e2ae4c2655054acf20b), [#1785](https://github.com/heygen-com/hyperframes/pull/1785))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.20...v0.7.21).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.20"
|
||||
description="Released - 2026-06-30"
|
||||
tags={["Release", "Producer,lint", "Producer", "Core"]}
|
||||
>
|
||||
This release fixes a render-correctness bug where a video or audio element identified only by a `data-hf-id` (no real `id`) rendered as a blank white wash with dropped audio. It also restores Studio keyframe retiming (drag-to-retime and Move to Playhead) and ships capture and runtime reliability improvements.
|
||||
|
||||
## Features
|
||||
|
||||
- **Studio:** Restore keyframe retiming — drag-to-retime + Move to Playhead (closes #1782) ([b403c54a](https://github.com/heygen-com/hyperframes/commit/b403c54ae7b8d3f58808600f7cc69a004fbd93f5), [#1784](https://github.com/heygen-com/hyperframes/pull/1784))
|
||||
- **CLI:** Expose Studio selection through preview ([9983f37c](https://github.com/heygen-com/hyperframes/commit/9983f37c13dc7d8fbd35da21e88229e3500cee9a), [#1777](https://github.com/heygen-com/hyperframes/pull/1777))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Producer,lint:** Id-less media renders blank wash instead of footage ([74f9c31b](https://github.com/heygen-com/hyperframes/commit/74f9c31b3fdeacf3a5700b17cbe718d7b00e54d2), [#1790](https://github.com/heygen-com/hyperframes/pull/1790))
|
||||
- **Producer:** Keep video captures viewport-bound on software ([c9613cd8](https://github.com/heygen-com/hyperframes/commit/c9613cd8260563d92e4e87b4e68e27e836214398), [#1788](https://github.com/heygen-com/hyperframes/pull/1788))
|
||||
- **Core:** Publish runtime inline artifact ([e73076e9](https://github.com/heygen-com/hyperframes/commit/e73076e93c7d4dbcec779943fb0a801e8d10e31f), [#1787](https://github.com/heygen-com/hyperframes/pull/1787))
|
||||
- **Studio:** Keyframe/position editing correctness + thumbnail cache busting + local-studio preview discovery ([0a9555a0](https://github.com/heygen-com/hyperframes/commit/0a9555a0f7176583c1ac04d0f3cd4db977e0c023), [#1781](https://github.com/heygen-com/hyperframes/pull/1781))
|
||||
- **Render:** Avoid empty WAAPI scans and llvmpipe auto GPU ([fc0f8c31](https://github.com/heygen-com/hyperframes/commit/fc0f8c315102812a9629bcdeec09f75e26027da2), [#1775](https://github.com/heygen-com/hyperframes/pull/1775))
|
||||
|
||||
## Performance
|
||||
|
||||
- **Engine:** Reduce init overhead in headless capture sessions ([35a01d90](https://github.com/heygen-com/hyperframes/commit/35a01d9058fca2c4f5e9a4aa13a86d2eafc77c68), [#1718](https://github.com/heygen-com/hyperframes/pull/1718))
|
||||
|
||||
## Docs & Examples
|
||||
|
||||
- Trim README motion hero ([38c6cd11](https://github.com/heygen-com/hyperframes/commit/38c6cd1113738a95f2f360dc9d70571a47629151), [#1780](https://github.com/heygen-com/hyperframes/pull/1780))
|
||||
|
||||
## Internal
|
||||
|
||||
- **Skills:** Rebuild faceless-explainer + pr-to-video on the shot-sequence architecture ([7cb83865](https://github.com/heygen-com/hyperframes/commit/7cb83865390d642e4e09bc162ad8c4a002545c6b), [#1778](https://github.com/heygen-com/hyperframes/pull/1778))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.17...v0.7.20).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.19"
|
||||
description="Released - 2026-06-29"
|
||||
tags={["Release", "Core", "Studio", "Skills"]}
|
||||
>
|
||||
This release fixes the `@hyperframes/core` package by publishing the generated
|
||||
runtime inline module required by the top-level entrypoint. It also improves
|
||||
Studio keyframe editing and retiming, refreshes bundled skills, and trims the
|
||||
README motion preview.
|
||||
|
||||
## Features
|
||||
|
||||
- **Studio:** Restore keyframe retiming — drag-to-retime + Move to Playhead (closes #1782) ([b403c54a](https://github.com/heygen-com/hyperframes/commit/b403c54ae7b8d3f58808600f7cc69a004fbd93f5), [#1784](https://github.com/heygen-com/hyperframes/pull/1784))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Core:** Publish runtime inline artifact ([e73076e9](https://github.com/heygen-com/hyperframes/commit/e73076e93c7d4dbcec779943fb0a801e8d10e31f), [#1787](https://github.com/heygen-com/hyperframes/pull/1787))
|
||||
- **Studio:** Keyframe/position editing correctness + thumbnail cache busting + local-studio preview discovery ([0a9555a0](https://github.com/heygen-com/hyperframes/commit/0a9555a0f7176583c1ac04d0f3cd4db977e0c023), [#1781](https://github.com/heygen-com/hyperframes/pull/1781))
|
||||
|
||||
## Docs & Examples
|
||||
|
||||
- Trim README motion hero ([38c6cd11](https://github.com/heygen-com/hyperframes/commit/38c6cd1113738a95f2f360dc9d70571a47629151), [#1780](https://github.com/heygen-com/hyperframes/pull/1780))
|
||||
|
||||
## Internal
|
||||
|
||||
- **Skills:** Rebuild faceless-explainer + pr-to-video on the shot-sequence architecture ([7cb83865](https://github.com/heygen-com/hyperframes/commit/7cb83865390d642e4e09bc162ad8c4a002545c6b), [#1778](https://github.com/heygen-com/hyperframes/pull/1778))
|
||||
|
||||
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.18...v0.7.19).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.18"
|
||||
description="Released - 2026-06-28"
|
||||
|
||||
@@ -10,8 +10,8 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co
|
||||
| Attribute | Example | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `data-start` | `"0"` or `"intro"` | Start time in seconds, or a clip ID reference for [relative timing](#relative-timing) |
|
||||
| `data-duration` | `"5"` | Duration in seconds. Required for images. Optional for video/audio (defaults to source duration). Not used on compositions. |
|
||||
| `data-track-index` | `"0"` | Timeline track number. Controls z-ordering (higher = in front) and groups clips into rows. Clips on the same track cannot overlap. |
|
||||
| `data-duration` | `"5"` | Duration in seconds. Required for images and sub-compositions. Optional for video/audio (defaults to source duration). On the **root** composition it sets the total render length (see [Composition Attributes](#composition-attributes)). |
|
||||
| `data-track-index` | `"0"` | Timeline track number. Temporal ordering — groups clips into rows on the timeline. Clips on the same track cannot overlap. Does **not** control z-ordering (use CSS `z-index` for that). |
|
||||
|
||||
## Media Attributes
|
||||
|
||||
@@ -28,6 +28,7 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co
|
||||
| `data-composition-id` | `"root"` | Unique ID for [composition](/concepts/compositions) wrapper (required on every composition) |
|
||||
| `data-width` | `"1920"` | Composition width in pixels |
|
||||
| `data-height` | `"1080"` | Composition height in pixels |
|
||||
| `data-duration` (root) | `"30"` | On the **root** composition, the total render length / frame count in seconds. Read once from the source HTML at compile time, like `data-width` / `data-height`, so a script or `hyperframes render --variables` cannot change it (author it directly, one value per output). If the root omits `data-duration`, and only then, the renderer derives the total length from the live DOM / timeline after scripts run. |
|
||||
| `data-composition-src` | `"./intro.html"` | Path to external [composition](/concepts/compositions) HTML file |
|
||||
| `data-variable-values` | `'{"title":"Hello"}'` | JSON object of values passed to a nested composition. Inside the sub-composition, read them via `window.__hyperframes.getVariables()` — the runtime layers these over the sub-comp's own `data-composition-variables` defaults and exposes the merged result on a per-instance basis (the same source can be embedded multiple times with different values). |
|
||||
| `data-composition-variables` | `'[{"id":"title","type":"string","label":"Title","default":"Hello"}]'` | JSON array of declared variables (`id`, `type`, `label`, `default`). Drives Studio editing UI and provides defaults read by `window.__hyperframes.getVariables()`. The CLI flag `hyperframes render --variables '<json>'` overrides these defaults at top-level render time; host elements override them per-instance via `data-variable-values`. |
|
||||
|
||||
@@ -14,7 +14,7 @@ The rendering pipeline is frame-by-frame and seek-driven. No realtime playback i
|
||||
The [engine](/packages/engine) computes the time for each frame using integer math: `time = floor(frame) / fps`. There is no wall-clock dependency -- rendering is entirely decoupled from real time.
|
||||
</Step>
|
||||
<Step title="Seek">
|
||||
The [frame adapter](/concepts/frame-adapters) receives a `seekFrame(frame)` call and deterministically positions all animations, DOM state, and canvas content to the exact frame. The adapter's `renderSeek` pauses all [GSAP](/guides/gsap-animation) timelines and seeks them to the computed time.
|
||||
The [frame adapter](/concepts/frame-adapters) receives a `seekFrame(frame)` call and deterministically positions all animations, DOM state, and canvas content to the exact frame. The adapter pauses all [GSAP](/guides/gsap-animation) timelines and seeks them to the computed time.
|
||||
</Step>
|
||||
<Step title="Capture">
|
||||
Chrome's `HeadlessExperimental.beginFrame` API captures the pixel buffer for the current frame. This is a single, atomic operation -- no partial paints or race conditions.
|
||||
|
||||
@@ -116,6 +116,7 @@ All runtime adapters live in the `/hyperframes-animation` skill — invoke it fo
|
||||
| Lottie / dotLottie | `goToAndStop(timeMs, false)`, raw-frame setters, or player seek APIs | `/hyperframes-animation` |
|
||||
| Three.js / WebGL | `hf-seek` events plus `window.__hfThreeTime` for deterministic scene rendering | `/hyperframes-animation` |
|
||||
| Web Animations API | `document.getAnimations()` and `animation.currentTime` | `/hyperframes-animation` |
|
||||
| TypeGPU / WebGPU | GPU compute shaders with deterministic seek via `hf-seek` events | `/hyperframes-animation` |
|
||||
|
||||
Community adapters are welcome -- if it can seek by frame, it belongs in Hyperframes.
|
||||
|
||||
|
||||
@@ -83,14 +83,15 @@ Pass assets as URL references your composition resolves at render time; don't in
|
||||
Media color grading can also read exact variable references inside
|
||||
`data-color-grading`. Use `$name` or `${name}` as the entire value for a field;
|
||||
the runtime resolves it from the current composition's variables before applying
|
||||
the shader grading:
|
||||
the shader grading, finishing details, blur/pixelate effects, and optional LUT:
|
||||
|
||||
```html compositions/hero.html
|
||||
<html data-composition-variables='[
|
||||
{"id":"gradingPreset","type":"enum","label":"Color grading preset","default":"warm-clean",
|
||||
"options":[{"value":"warm-clean","label":"Warm Clean"},{"value":"cool-clean","label":"Cool Clean"}]},
|
||||
{"id":"gradingIntensity","type":"number","label":"Color grading intensity","default":0.75,"min":0,"max":1,"step":0.05},
|
||||
{"id":"gradingExposure","type":"number","label":"Exposure","default":0,"min":-2,"max":2,"step":0.05}
|
||||
{"id":"gradingPreset","type":"enum","label":"Preset","default":"natural-lift",
|
||||
"options":[{"value":"natural-lift","label":"Natural Lift"},{"value":"warm-daylight","label":"Warm Daylight"}]},
|
||||
{"id":"gradingIntensity","type":"number","label":"Preset strength","default":0.75,"min":0,"max":1,"step":0.05},
|
||||
{"id":"gradingExposure","type":"number","label":"Exposure","default":0,"min":-2,"max":2,"step":0.05},
|
||||
{"id":"gradingVibrance","type":"number","label":"Vibrance","default":0.08,"min":-1,"max":1,"step":0.01}
|
||||
]'>
|
||||
<body>
|
||||
<div data-composition-id="hero" data-width="1920" data-height="1080">
|
||||
@@ -104,7 +105,9 @@ the shader grading:
|
||||
data-color-grading='{
|
||||
"preset":"$gradingPreset",
|
||||
"intensity":"$gradingIntensity",
|
||||
"adjust":{"exposure":"${gradingExposure}"},
|
||||
"adjust":{"exposure":"${gradingExposure}","vibrance":"$gradingVibrance"},
|
||||
"details":{"vignette":0.15,"vignetteFeather":0.72,"grain":0.08,"grainSize":0.25},
|
||||
"effects":{"blur":0.1},
|
||||
"colorSpace":"rec709"
|
||||
}'
|
||||
></video>
|
||||
@@ -142,7 +145,11 @@ If you need to clamp or pin the clip to a specific length per render — for exa
|
||||
</script>
|
||||
```
|
||||
|
||||
The probe phase reads `data-duration` from the live DOM after your script runs, so an attribute written programmatically behaves identically to one baked into the source HTML.
|
||||
The probe phase reads a **clip's** `data-duration` from the live DOM after your script runs, so an attribute written programmatically onto a clip or media element behaves identically to one baked into the source HTML.
|
||||
|
||||
<Warning>
|
||||
This live-DOM re-read applies to clip and media elements, **not** to the **root composition's own `data-duration`** (its total render length / frame count). The renderer reads the root `data-duration` once at compile time, before your scripts run, exactly like `data-width` / `data-height`. If the root element carries a static `data-duration`, a script (or a variable) that rewrites it afterward is ignored, and the render uses the compile-time value. To make total render length vary per render, author the root `data-duration` directly (one value per output) rather than trying to drive it from a script. See [What can't be a variable](#what-cant-be-a-variable).
|
||||
</Warning>
|
||||
|
||||
## What can't be a variable
|
||||
|
||||
@@ -150,12 +157,13 @@ A small set of inputs are read once from the source HTML or from the CLI / SDK,
|
||||
|
||||
| What | Mechanism (not a variable) |
|
||||
|------|----------------------------|
|
||||
| Composition dimensions | `data-width` / `data-height` on the composition element — parsed from the source HTML at compile time, not from the live DOM |
|
||||
| Composition dimensions | `data-width` / `data-height` on the composition element, parsed from the source HTML at compile time, not from the live DOM |
|
||||
| Root composition total duration | `data-duration` on the **root** composition element (the total render length / frame count), parsed from the source HTML at compile time. A static root `data-duration` is locked before scripts run, so neither a script nor a variable can change the render length. (A clip's own `data-duration` is different: it is re-read from the live DOM, as shown above.) |
|
||||
| Frame rate | `--fps` flag on `hyperframes render`, or `config.fps` in the SDK |
|
||||
| Output format / codec / quality | `--format` / `--codec` / `--quality` flags, or the SDK equivalents |
|
||||
| A sibling or parent composition's variables | Variables are per-composition; use [`data-variable-values`](#per-instance-overrides-sub-compositions) on each sub-comp host element to pass overrides |
|
||||
|
||||
The deeper rule: variables are runtime values your script applies to the DOM. They can drive anything the renderer reads from the live DOM after that script runs — text, colors, media `src`, even clip `data-duration` as shown above. They can't change inputs the renderer reads once at compile time (dimensions) or that live entirely outside the composition (CLI flags, encoder settings).
|
||||
The deeper rule: variables are runtime values your script applies to the DOM. They can drive anything the renderer reads from the live DOM after that script runs: text, colors, media `src`, even clip `data-duration` as shown above. They can't change inputs the renderer reads once at compile time (composition dimensions, and the root composition's total duration / render length) or that live entirely outside the composition (CLI flags, encoder settings).
|
||||
|
||||
## Reading Variables at Runtime
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ The Lambda handler is a thin dispatch: parse the Step Functions event, download
|
||||
|
||||
| Tool | Why | Install |
|
||||
|------|-----|---------|
|
||||
| AWS credentials | The CLI and the deploy step both call AWS APIs. | Env vars, `~/.aws/credentials`, SSO, or IMDS — any chain `boto3` would resolve. |
|
||||
| AWS credentials | The CLI and the deploy step both call AWS APIs. | Env vars, `~/.aws/credentials`, SSO, or IMDS — any chain the AWS SDK for JavaScript v3 would resolve. |
|
||||
| AWS SAM CLI | `hyperframes lambda deploy/destroy` shells out to `sam deploy`/`sam delete`. | [Install guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) |
|
||||
| `bun` | Used to build `packages/aws-lambda/dist/handler.zip` at deploy time. | `npm install -g bun` or [bun.sh](https://bun.sh) |
|
||||
| HyperFrames repo checkout | `lambda deploy` builds the Lambda handler ZIP from source. Adopters who deploy outside a checkout can set `HYPERFRAMES_REPO_ROOT` to point at one. | `git clone https://github.com/heygen-com/hyperframes` |
|
||||
|
||||
@@ -44,7 +44,7 @@ gcloud builds submit . \
|
||||
--tag us-central1-docker.pkg.dev/PROJECT/hyperframes/hyperframes-render:v1
|
||||
|
||||
# 2. Apply the module.
|
||||
cd node_modules/@hyperframes/gcp-cloud-run/terraform
|
||||
cd packages/gcp-cloud-run/terraform
|
||||
terraform init
|
||||
terraform apply \
|
||||
-var project_id=PROJECT \
|
||||
@@ -96,4 +96,4 @@ examples/gcp-cloud-run/scripts/smoke.sh --project my-project --region us-central
|
||||
|
||||
## Supported formats
|
||||
|
||||
Same as the distributed pipeline everywhere: `mp4` (H.264 / H.265), `mov` (ProRes), `webm` (VP9), and `png-sequence`. HDR mp4 is not supported in distributed mode.
|
||||
Same as the distributed pipeline everywhere: `mp4` (H.264 / H.265), `webm` (VP9), `mov` (ProRes), and `png-sequence`. HDR mp4 is not supported in distributed mode.
|
||||
|
||||
@@ -78,8 +78,10 @@
|
||||
"guides/skills",
|
||||
"guides/authentication",
|
||||
"guides/video-components",
|
||||
"guides/color-grading",
|
||||
"guides/html-in-canvas",
|
||||
"guides/website-to-video",
|
||||
"guides/figma",
|
||||
"guides/antigravity",
|
||||
"guides/copilot-cli",
|
||||
"guides/claude-design",
|
||||
@@ -339,6 +341,41 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "SDK",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Overview",
|
||||
"pages": [
|
||||
"sdk/overview",
|
||||
"sdk/quickstart"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Guides",
|
||||
"pages": [
|
||||
"sdk/guides/querying-and-editing",
|
||||
"sdk/guides/timing-and-animation",
|
||||
"sdk/guides/undo-redo-and-patches",
|
||||
"sdk/guides/persistence",
|
||||
"sdk/guides/embedded-override-mode",
|
||||
"sdk/guides/canvas-integration",
|
||||
"sdk/guides/editing-affordances"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Reference",
|
||||
"pages": [
|
||||
"sdk/reference/open-composition",
|
||||
"sdk/reference/composition",
|
||||
"sdk/reference/edit-operations",
|
||||
"sdk/reference/types",
|
||||
"sdk/reference/adapters",
|
||||
"sdk/reference/utilities"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Reference",
|
||||
"groups": [
|
||||
|
||||
@@ -58,8 +58,10 @@ Hyperframes renders to 4K (3840×2160) two ways. Both produce a true 4K MP4; pic
|
||||
|--------|-----------|---------|
|
||||
| `landscape` | 1920×1080 | `1080p`, `hd` |
|
||||
| `portrait` | 1080×1920 | `1080p-portrait` |
|
||||
| `square` | 1080×1080 | `1080p-square`, `square-1080p` |
|
||||
| `landscape-4k` | 3840×2160 | `4k`, `uhd` |
|
||||
| `portrait-4k` | 2160×3840 | `4k-portrait` |
|
||||
| `square-4k` | 2160×2160 | `4k-square` |
|
||||
|
||||
Examples:
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Antigravity uses **semantic matching** — when you type a prompt, the agent com
|
||||
| "Create a 10-second product intro with captions" | `hyperframes`, `hyperframes-core` |
|
||||
| "Add a GSAP scale-pop to the title" | `hyperframes-animation` |
|
||||
| "Use Tailwind for styling" | `hyperframes-core` |
|
||||
| "Transcribe this audio and add captions" | `hyperframes-media` |
|
||||
| "Transcribe this audio and add captions" | `media-use` |
|
||||
| "Add a shimmer sweep transition" | `hyperframes-registry` |
|
||||
|
||||
The skill is only injected into the agent's context window when it matches — this keeps the context clean and focused.
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
---
|
||||
title: Color Grading
|
||||
description: "Apply real-time color grading, presets, LUTs, vignette, grain, blur, and pixelate to video and image media in Studio and final renders."
|
||||
---
|
||||
|
||||
HyperFrames Studio can color grade project-local `<video>` and `<img>` media directly in the preview. The same `data-color-grading` settings are used by the render pipeline, so the exported video should match the look you preview.
|
||||
|
||||
This is a lightweight media color tool for generated videos, uploaded footage, social variants, and agent-authored compositions. It is not a DaVinci Resolve, Premiere, ACES, or OCIO finishing pipeline.
|
||||
|
||||
## What Is New
|
||||
|
||||
| Capability | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| Studio Color Grading panel | Supported | Appears on selected `<video>` and `<img>` elements. |
|
||||
| Manual controls | Supported | Exposure, contrast, highlights, shadows, white point, black point, warmth, tint, vibrance, saturation. |
|
||||
| Presets | Supported | Named HyperFrames presets backed by shader settings, not bundled third-party LUT packs. |
|
||||
| Custom LUT upload | Supported | Project-local 3D `.cube` LUT files with strength control. |
|
||||
| Finishing | Supported | Vignette and grain, with advanced settings behind the settings icon. |
|
||||
| Effects | Supported | Blur and pixelate on the selected media surface. |
|
||||
| Before preview | Supported | Hold the compare button to temporarily show the ungraded media. |
|
||||
| Render parity | Supported | The render pipeline redraws the color-grading shader after video-frame injection. |
|
||||
|
||||
Studio labels `whites`, `blacks`, and `temperature` as White Point, Black Point, and Warmth. Use the JSON keys shown in the data shape when authoring `data-color-grading` by hand or through an agent.
|
||||
|
||||
## Support Matrix
|
||||
|
||||
| Source / workflow | Supported? | What to expect |
|
||||
| --- | --- | --- |
|
||||
| 1080p SDR video | Yes | Best default path. Good for most uploaded/generated MP4/WebM/MOV media that browsers can decode. |
|
||||
| 4K SDR video | Yes | Works when the browser and machine can decode it. Preview/render cost is higher. A 4K source only produces 4K output when the composition/render is also 4K. |
|
||||
| 1080p / 4K images | Yes | Works on normal project-local images. Output resolution follows the element/composition render size, not hidden extra detail beyond that size. |
|
||||
| iPhone SDR video | Yes | Treat as normal SDR media when it is tagged/decoded as SDR. |
|
||||
| iPhone HDR / HLG / Dolby Vision-style uploads | Partial | The media can be loaded if browser/FFmpeg support the file, and Studio warns when HDR metadata is detected. The live Color Grading shader is still an SDR preview path, not true HDR grading. Use the existing [HDR Rendering](/guides/hdr) pipeline for HDR delivery and verify the output. |
|
||||
| HDR render output | Related, not new | HyperFrames already has HDR render support. Color Grading does not yet provide HDR-aware grading controls. |
|
||||
| LOG camera footage | Partial | Sliders and LUTs can be applied, but HyperFrames does not auto-detect camera LOG profiles or apply ACES/OCIO input transforms. Use a matching conversion/look LUT if you know the source profile. |
|
||||
| Rec.709 creative LUTs | Yes | Best LUT path today. Use project-local 3D `.cube` files. |
|
||||
| Camera conversion LUTs | Partial | Technically accepted if they are supported 3D `.cube` files, but correctness depends on the source footage matching the LUT's expected input color space. |
|
||||
| Full-scene grading including text/DOM | Not yet | Color Grading is media-only. Captions, text, SVG, and regular DOM overlays stay unchanged. |
|
||||
| Remote media URLs | Partial | WebGL pixel processing requires compatible CORS headers. Project-local assets are the reliable path. |
|
||||
| Professional ACES/OCIO/HDR finishing | Not yet | Future render/color-management work, not this Studio shader path. |
|
||||
|
||||
## How It Works
|
||||
|
||||
Color grading is stored on media elements as `data-color-grading`:
|
||||
|
||||
```html index.html
|
||||
<video
|
||||
id="hero-video"
|
||||
src="assets/hero.mp4"
|
||||
data-start="0"
|
||||
data-duration="6"
|
||||
muted
|
||||
playsinline
|
||||
data-color-grading='{
|
||||
"preset":"clean-studio",
|
||||
"intensity":0.85,
|
||||
"adjust":{
|
||||
"exposure":0.05,
|
||||
"contrast":0.08,
|
||||
"highlights":-0.08,
|
||||
"shadows":0.06,
|
||||
"vibrance":0.04,
|
||||
"saturation":0.04
|
||||
},
|
||||
"details":{
|
||||
"vignette":0.08,
|
||||
"vignetteFeather":0.72,
|
||||
"grain":0.12,
|
||||
"grainSize":0.25,
|
||||
"grainRoughness":0.55
|
||||
},
|
||||
"effects":{
|
||||
"blur":0.08
|
||||
},
|
||||
"colorSpace":"rec709"
|
||||
}'
|
||||
></video>
|
||||
```
|
||||
|
||||
The runtime creates a sibling WebGL canvas for the media element, samples the current video or image frame, applies shader uniforms, then hides the native media only after a shader frame is ready.
|
||||
|
||||
<Note>
|
||||
Color Grading is intentionally **media-only**. It applies to `<video>` and `<img>` sources. Captions, text, divs, SVG, and UI graphics remain ungraded unless you render them into media first.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Project-local media is the safest path. Remote media must be served with compatible CORS headers and should use `crossorigin="anonymous"` when pixel processing is needed.
|
||||
</Note>
|
||||
|
||||
## Data Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"preset": "natural-lift",
|
||||
"intensity": 1,
|
||||
"adjust": {
|
||||
"exposure": 0,
|
||||
"contrast": 0,
|
||||
"highlights": 0,
|
||||
"shadows": 0,
|
||||
"whites": 0,
|
||||
"blacks": 0,
|
||||
"temperature": 0,
|
||||
"tint": 0,
|
||||
"vibrance": 0,
|
||||
"saturation": 0
|
||||
},
|
||||
"details": {
|
||||
"vignette": 0,
|
||||
"vignetteMidpoint": 0.5,
|
||||
"vignetteRoundness": 0,
|
||||
"vignetteFeather": 0.65,
|
||||
"grain": 0,
|
||||
"grainSize": 0.25,
|
||||
"grainRoughness": 0.5
|
||||
},
|
||||
"effects": {
|
||||
"blur": 0,
|
||||
"pixelate": 0
|
||||
},
|
||||
"lut": {
|
||||
"src": "assets/luts/look.cube",
|
||||
"intensity": 0.75
|
||||
},
|
||||
"colorSpace": "rec709"
|
||||
}
|
||||
```
|
||||
|
||||
Omit `enabled`; the presence of `data-color-grading` implies that grading is active. All numeric controls are clamped by the runtime. The current color grading path is Rec.709/sRGB-oriented and assumes browser-decoded media frames.
|
||||
|
||||
## Custom LUTs
|
||||
|
||||
HyperFrames supports project-local 3D `.cube` LUT files:
|
||||
|
||||
```html index.html
|
||||
<img
|
||||
src="assets/product.jpg"
|
||||
data-color-grading='{
|
||||
"lut":{"src":"assets/luts/product-pop.cube","intensity":0.7}
|
||||
}'
|
||||
/>
|
||||
```
|
||||
|
||||
Use `.cube` LUTs when users already have a look from another editor or camera workflow.
|
||||
|
||||
- HyperFrames currently supports 3D `.cube` LUTs for this path.
|
||||
- 3D cube LUTs up to `LUT_3D_SIZE 64` are supported.
|
||||
- 1D `.cube` LUTs and mixed 1D+3D LUT files are not supported yet.
|
||||
- Supported headers include common `DOMAIN_MIN` / `DOMAIN_MAX` and DaVinci/IRIDAS-style `LUT_3D_INPUT_RANGE`.
|
||||
- LUTs are not universal. A LUT looks correct only when the source footage roughly matches the LUT's expected input color space.
|
||||
- Rec.709 creative LUTs are the safest fit today.
|
||||
- LOG/camera conversion LUTs can be used, but HyperFrames does not yet manage camera color profiles for you.
|
||||
|
||||
## Render Behavior
|
||||
|
||||
Color Grading is part of the media runtime, so render uses the same settings as Studio preview. During render, HyperFrames injects exact video frames and asks the color-grading runtime to redraw before capture.
|
||||
|
||||
For 4K output, use the existing [4K Rendering](/guides/4k-rendering) workflow. Color Grading can run at 4K when the composition/render surface is 4K, but a 1080p source video does not become sharper just because the final render is 4K.
|
||||
|
||||
For HDR output, use the existing [HDR Rendering](/guides/hdr) workflow. Color Grading currently warns on detected HDR media, but the grading controls themselves are not HDR-aware.
|
||||
|
||||
When grading a video, animate opacity on a wrapper element instead of directly on the `<video>` element. The runtime hides the native media and draws the graded result through a sibling canvas, so wrapper opacity preserves preview/render parity.
|
||||
|
||||
## What Belongs Where
|
||||
|
||||
| User wants | Use |
|
||||
| --- | --- |
|
||||
| Make uploaded footage look cleaner | Color Grading preset + adjust controls |
|
||||
| Use a look from another editor | Custom 3D `.cube` LUT |
|
||||
| Add polish to a product shot | Vignette, subtle grain, contrast, vibrance |
|
||||
| Blur or pixelate selected media | Effects inside Color Grading |
|
||||
| Make a presenter float over graphics | Existing [Remove Background](/guides/remove-background) workflow |
|
||||
| Put text behind a presenter | Existing `remove-background --background-output` workflow |
|
||||
| Render HDR delivery files | Existing [HDR Rendering](/guides/hdr) workflow |
|
||||
| Render 4K | Existing [4K Rendering](/guides/4k-rendering) workflow |
|
||||
| Remove a person and reconstruct the room | External video inpainting, not HyperFrames background removal |
|
||||
| Green screen keying | Preprocess with FFmpeg `chromakey` or a future HyperFrames command |
|
||||
| Grade every pixel in the full scene including captions/DOM | Future compositor or render post-process |
|
||||
| Professional ACES/OCIO color pipeline | Future high-fidelity color-management pipeline |
|
||||
|
||||
## Agent-Friendly Examples
|
||||
|
||||
For AI agents, keep the instruction declarative:
|
||||
|
||||
```text
|
||||
Apply a clean studio preset to assets/interview.mp4, reduce highlights slightly,
|
||||
lift shadows, add subtle vignette, and keep captions ungraded.
|
||||
```
|
||||
|
||||
Expected markup:
|
||||
|
||||
```html index.html
|
||||
<video
|
||||
id="interview"
|
||||
src="assets/interview.mp4"
|
||||
data-start="0"
|
||||
data-duration="6"
|
||||
muted
|
||||
playsinline
|
||||
data-color-grading='{
|
||||
"preset":"clean-studio",
|
||||
"intensity":0.8,
|
||||
"adjust":{"highlights":-0.08,"shadows":0.08},
|
||||
"details":{"vignette":0.08}
|
||||
}'
|
||||
></video>
|
||||
```
|
||||
|
||||
## Related Guides
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Remove Background" icon="scissors" href="/guides/remove-background">
|
||||
Create transparent video/image cutouts for presenter and product overlays.
|
||||
</Card>
|
||||
<Card title="Rendering" icon="film" href="/guides/rendering#transparent-background">
|
||||
Render transparent overlays and final MP4/WebM/MOV outputs.
|
||||
</Card>
|
||||
<Card title="HDR Rendering" icon="sun" href="/guides/hdr">
|
||||
Render HDR10 outputs when your project uses HDR video or image sources.
|
||||
</Card>
|
||||
<Card title="4K Rendering" icon="up-right-and-down-left-from-center" href="/guides/4k-rendering">
|
||||
Render at 4K and understand what supersampling does and does not improve.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -92,6 +92,30 @@ These are mistakes that cannot be caught by the linter. For automated checks, ru
|
||||
</Tip>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Setting the root render length from a script or --variables does nothing">
|
||||
**Symptom:** You set the **root** composition's `data-duration` from a script (or expose it as a variable and pass `--variables`), but the render length never changes. It stays locked at whatever `data-duration` is written in the source HTML.
|
||||
|
||||
**Cause:** The root composition's `data-duration` (the total render length / frame count) is read **once at compile time**, before your scripts run, exactly like `data-width` / `data-height`. A static root `data-duration` is locked at that point, so a later `root.setAttribute("data-duration", ...)` or a `--variables`-driven value is ignored. This is unlike a **clip's** `data-duration`, which the renderer re-reads from the live DOM after scripts run.
|
||||
|
||||
**Before (broken):**
|
||||
|
||||
```html index.html
|
||||
<div id="root" data-composition-id="main" data-duration="2" data-width="1920" data-height="1080">
|
||||
...
|
||||
</div>
|
||||
<script>
|
||||
// Ignored: the root render length was already fixed at 2s at compile time.
|
||||
document.getElementById("root").setAttribute("data-duration", "5");
|
||||
</script>
|
||||
```
|
||||
|
||||
**After (fixed):** author the root `data-duration` directly, one value per output. To produce a different length, author (or generate) a variant with that root `data-duration`. See [What can't be a variable](/concepts/variables#what-cant-be-a-variable).
|
||||
|
||||
<Tip>
|
||||
Only the root composition's *total length* is compile-time-fixed. A clip's own `data-duration` (and a `<video>`/`<audio>` trim length) is re-read from the live DOM, so scripts and variables can still drive those, see [Swapping media: do you need to vary duration too?](/concepts/variables#swapping-media-do-you-need-to-vary-duration-too).
|
||||
</Tip>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Missing class='clip' on timed elements">
|
||||
**Symptom:** Elements are always visible, ignoring their `data-start` and `data-duration` timing.
|
||||
|
||||
|
||||
@@ -74,9 +74,28 @@ hyperframes feedback --rating 3 --comment "render succeeded but GSAP timeline di
|
||||
|------|-------------|
|
||||
| `--rating` | Satisfaction score, 1–5 (required) |
|
||||
| `--comment` | Optional free-text details |
|
||||
| `--file-issue` | Also open a pre-filled GitHub issue with a published minimal repro (opt-in) |
|
||||
| `--dir` | Project directory to publish as the repro (default: current directory) |
|
||||
| `--yes` | Skip the publish + file-issue consent prompt (for scripts) |
|
||||
|
||||
This command collects a doctor summary automatically, flushes telemetry, and exits. It appears under the **Settings** group in `hyperframes --help`.
|
||||
|
||||
### Filing a GitHub issue (`--file-issue`)
|
||||
|
||||
When a render misbehaves, add `--file-issue` so maintainers can reproduce it:
|
||||
|
||||
```bash
|
||||
hyperframes feedback --rating 2 --comment "GSAP timeline froze on seek" --file-issue
|
||||
```
|
||||
|
||||
This is **opt-in** and **consented**. With `--file-issue` set, after the usual feedback is sent the CLI:
|
||||
|
||||
1. Asks you to confirm (interactive) or requires `--yes` in non-interactive shells, because it will **publicly publish** the project at `--dir`.
|
||||
2. Publishes a minimal repro of the project and gets a public URL (the same upload as `hyperframes publish`).
|
||||
3. Opens your browser with a **pre-filled** GitHub issue draft, labelled `bug`, containing the rating, your comment, the public repro link, and the environment summary. It also prints the URL so you can copy it if no browser opens.
|
||||
|
||||
The issue is **not auto-submitted**: you review and file it under your own GitHub account. There is no token, backend, or `gh` invocation; if publishing fails the issue still opens, just without a repro link.
|
||||
|
||||
## Agent Runtimes
|
||||
|
||||
When an AI agent is detected, HyperFrames **skips the interactive readline prompt** and prints a structured hint instead:
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
title: Figma Import
|
||||
description: "Bring Figma designs into HyperFrames — frozen assets, brand tokens, editable components, storyboard reconstruction, and Figma Motion timelines translated to GSAP."
|
||||
---
|
||||
|
||||
The work your designer already did in Figma — layout, color, type, motion — becomes the starting point of a composition instead of a thing to rebuild by hand. Point at a Figma URL; the artifact lands as a native HyperFrames piece: a frozen local file, a composition variable, editable HTML, or a paused GSAP timeline.
|
||||
|
||||
## What you can import
|
||||
|
||||
| Capability | What you get | Surface |
|
||||
| --- | --- | --- |
|
||||
| **Static assets** | A frame/layer rendered to SVG/PNG/JPG/PDF, frozen under `.media/` | `hyperframes figma asset` |
|
||||
| **Brand tokens** | Figma variables/styles as composition brand variables | `hyperframes figma tokens` |
|
||||
| **Components** | A frame as editable HTML with brand-linked colors | `hyperframes figma component` |
|
||||
| **Motion** | A Figma Motion timeline as an editable, paused GSAP timeline | `/figma` skill (agent, MCP) |
|
||||
| **Shaders** | A shader fill/effect as a frozen still or clip | `/figma` skill (agent, MCP) |
|
||||
| **Storyboards** | Scene frames reconstructed as motion — frames read as states, not slides | `/figma` skill (agent) |
|
||||
|
||||
Two transports, split by what Figma exposes: assets, tokens, and components run over the **REST API** (headless, works in CI, generous per-minute rate limits). Motion and shaders exist only on Figma's **MCP server**, so an agent drives those. Every path freezes files locally — **renders never call Figma**.
|
||||
|
||||
## One-time setup
|
||||
|
||||
The CLI paths need a Figma personal access token in the `FIGMA_TOKEN` environment variable.
|
||||
|
||||
<Steps>
|
||||
<Step title="Mint a token">
|
||||
In Figma: **Settings → Security → Personal access tokens → Generate new token.**
|
||||
</Step>
|
||||
<Step title="Pick read-only scopes">
|
||||
The integration never writes to Figma — read-only is all it ever needs:
|
||||
|
||||
| Scope | Setting | Needed for |
|
||||
| --- | --- | --- |
|
||||
| File content | Read-only | assets, components |
|
||||
| File metadata | Read-only | version tracking, refresh |
|
||||
| Variables | Read-only | brand variables — **Figma Enterprise only** |
|
||||
|
||||
No Enterprise plan? Skip the Variables scope — `tokens` automatically falls back to your published styles. That's expected behavior, not an error.
|
||||
</Step>
|
||||
<Step title="Export it">
|
||||
```bash
|
||||
export FIGMA_TOKEN="figd_…"
|
||||
```
|
||||
|
||||
Add the line to your shell profile or the project's `.env` so future sessions skip this step. The same token covers every Figma file your account can view.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Motion and shader import use the **Figma MCP connector** instead — a one-click OAuth from your agent, separate from the token. Connect it when your agent asks; no scopes to configure.
|
||||
|
||||
## Import an asset
|
||||
|
||||
```bash
|
||||
hyperframes figma asset 'https://www.figma.com/design/KEY/Title?node-id=1-2'
|
||||
```
|
||||
|
||||
The node renders over REST, lands frozen under `.media/images/`, and the command prints a ready-to-paste `<img>` snippet:
|
||||
|
||||
```text
|
||||
imported image_007 → .media/images/image_007.svg
|
||||
<img src=".media/images/image_007.svg" alt="image_007" data-figma-id="1:2" />
|
||||
```
|
||||
|
||||
- `--format svg|png|jpg|pdf` (default `svg`). SVG for logos and vectors — scalable and animatable. `--format png --scale 2` for raster fidelity.
|
||||
- Accepted refs: a full Figma URL with `?node-id=…` (right-click a layer → Copy link) or `fileKey:nodeId` shorthand. Asset and component imports always target a specific node; only `tokens` takes a bare `fileKey`.
|
||||
- Idempotent: the manifest records `fileKey:nodeId:format:scale:version`, so re-running reuses the file unless the design actually changed in Figma.
|
||||
|
||||
## Pull your brand
|
||||
|
||||
```bash
|
||||
hyperframes figma tokens KEY
|
||||
```
|
||||
|
||||
Reads the file's variables (or published styles), writes a `figma-tokens.json` sidecar plus a binding index, and prints entries for the composition's `data-composition-variables`. Every scene that references a brand role — instead of a hard-coded hex — is on-brand automatically, and stays on-brand when the file changes.
|
||||
|
||||
<Tip>
|
||||
Import tokens **before** components. That's what lets an imported component's colors link to your brand variables instead of baking duplicate literals.
|
||||
</Tip>
|
||||
|
||||
## Import a component
|
||||
|
||||
```bash
|
||||
hyperframes figma component 'https://www.figma.com/design/KEY/Title?node-id=10-20'
|
||||
```
|
||||
|
||||
The frame's node tree becomes editable HTML at exact Figma geometry, packaged under `compositions/components/<name>/`. Vector and boolean-op nodes that don't map to clean HTML auto-rasterize through the asset path.
|
||||
|
||||
Colors bound to a Figma variable resolve against your imported tokens:
|
||||
|
||||
- Bound to an **imported** token → emitted as `var(--brand-slug, #0066FF)` — a later brand refresh propagates into the component.
|
||||
- Bound to a token you **haven't imported** → the literal color is used and the element is flagged `data-figma-unresolved`. The command tells you; run `tokens` on the source (or library) file and re-import to link them.
|
||||
|
||||
Matching is by exact Figma ID only — never by hex value — so a coincidentally-shared color can't create a false brand link.
|
||||
|
||||
## Motion, shaders, and storyboards
|
||||
|
||||
These run through the `/figma` agent skill:
|
||||
|
||||
- **Motion** — a Figma Motion timeline (keyframes, easing, repeats) translates structurally into a paused, finite GSAP timeline registered on `window.__timelines`, seekable frame-by-frame like any hand-authored animation, and editable afterward. Tracks that can't translate faithfully fall back to a baked video clip — the agent tells you which path it took and why.
|
||||
- **Shaders** — Figma's export path doesn't execute shaders, so the default is a native Figma export (PNG or Motion MP4) imported as an asset/clip.
|
||||
- **Storyboards** — a section of scene frames is decoded, not slideshowed: frames sharing an element are treated as that element's keyframes over time, diffed into element chains and tweened, with text under the strip read as director notes. When the frames depict one product UI in successive states, the agent escalates further — rebuilds the UI as live DOM and performs each frame delta as a real interaction (cursor, click, navigation), so the result reads as a continuous screen recording. See the `/figma` skill for the full grammar.
|
||||
|
||||
## Provenance and refresh
|
||||
|
||||
Every import records where it came from (`fileKey`, `nodeId`, `version`) in `.media/manifest.jsonl`. Nothing in a rendered composition points at Figma — assets are files, tokens are variables, motion is a timeline. When the Figma file moves on, re-running the same import commands re-pulls only what changed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Error | Meaning | Fix |
|
||||
| --- | --- | --- |
|
||||
| `NO_TOKEN` | `FIGMA_TOKEN` unset | Follow [One-time setup](#one-time-setup) |
|
||||
| `BAD_TOKEN` (401) | Token expired or revoked | Re-mint the token |
|
||||
| `FORBIDDEN` (403) | Token missing a read scope, or no access to the file | Check the read-only scopes above and file visibility |
|
||||
| `REQUIRES_ENTERPRISE` (403) | Variables API needs Figma Enterprise | Not a failure — the styles fallback already ran |
|
||||
| `RATE_LIMITED` (429) | REST per-minute budget hit | Wait a minute and retry; chunk batch renders |
|
||||
| "Render timeout" on batch export | Too many large frames in one `/v1/images` call | Chunk to ~4 ids per call |
|
||||
| `ref has no node id` | Link points at a file, not a node | Copy the link with `?node-id=…` (right-click layer → Copy link) |
|
||||
@@ -48,7 +48,7 @@ Include GSAP and create a paused timeline:
|
||||
|
||||
## Supported Properties
|
||||
|
||||
`opacity`, `x`, `y`, `scale`, `scaleX`, `scaleY`, `rotation`, `width`, `height`, `visibility`, `color`, `backgroundColor`, and any CSS-animatable property.
|
||||
`opacity`, `x`, `y`, `scale`, `scaleX`, `scaleY`, `rotation`, `color`, `backgroundColor`, and other CSS-animatable transform/color properties. Do **not** animate `visibility`, `width`, or `height` — these break deterministic rendering.
|
||||
|
||||
## Timeline Duration and Composition Duration
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ When you open a composition in Studio, the timeline shows **diamond markers** on
|
||||
|
||||
Select any animated element in the preview or timeline to open the Design Panel. The **Animation** section shows:
|
||||
|
||||
- **Method badge** — `Animate`, `Animate In`, or `Animate Out` (maps to `.to()`, `.from()`, `.fromTo()`)
|
||||
- **Method badge** — `Animate`, `Animate In`, or `From → To` (maps to `.to()`, `.from()`, `.fromTo()`)
|
||||
- **Timing** — Length (duration) and Starts at (position on timeline)
|
||||
- **Speed** — The GSAP ease (e.g., `power2.inOut`, `back.out(3)`)
|
||||
- **Speed curve** — Visual preview of the easing function
|
||||
|
||||
+28
-29
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: The Pipeline
|
||||
description: "The 7-step pipeline for producing any Hyperframes video: capture, design, script, storyboard, voiceover, build, validate."
|
||||
description: "The 7-step pipeline for producing any Hyperframes video: capture, design, strategy & messaging, storyboard + script, voiceover, build, validate."
|
||||
---
|
||||
|
||||
Every well-structured Hyperframes video flows through the same 7 steps, whether it starts from a website, a PDF, a CSV, or a blank page. Each step produces a named artifact that the next step depends on, so your AI agent (and you) always know what's done, what's next, and where the creative decisions live on disk.
|
||||
@@ -11,15 +11,15 @@ This pipeline is the backbone of the [website-to-video workflow](/guides/website
|
||||
|
||||
Each step produces an artifact that feeds the next:
|
||||
|
||||
| # | Step | Output | What happens |
|
||||
|---|---------------|-----------------------------------------|-------------------------------------------------------------------------|
|
||||
| 1 | **Capture** | `capture/` | Extract screenshots, design tokens, fonts, assets, animations from a source |
|
||||
| 2 | **Design** | `DESIGN.md` | Brand reference: colors, typography, components, do's and don'ts |
|
||||
| 3 | **Script** | `SCRIPT.md` | Narration text with hook, story, proof, and CTA |
|
||||
| 4 | **Storyboard**| `STORYBOARD.md` | Per-beat creative direction: mood, assets, animations, transitions |
|
||||
| 5 | **VO + Timing**| `narration.wav` + `transcript.json` | TTS audio with word-level timestamps |
|
||||
| 6 | **Build** | `compositions/*.html` | Animated HTML compositions, one per beat |
|
||||
| 7 | **Validate** | Snapshot PNGs + `lint`/`validate` pass | Visual verification and runtime checks before delivery |
|
||||
| # | Step | Output | What happens |
|
||||
|---|-------------------------|-----------------------------------------|-------------------------------------------------------------------------|
|
||||
| 1 | **Capture** | `capture/` | Extract screenshots, design tokens, fonts, assets, animations from a source |
|
||||
| 2 | **Design** | `DESIGN.md` | Brand reference: colors, typography, component stylings, spacing, iteration guide |
|
||||
| 3 | **Strategy & Messaging**| — | Align on video type, style, the ONE message, narrative arc, and audience |
|
||||
| 4 | **Storyboard + Script** | `STORYBOARD.md` + `SCRIPT.md` | Concept-first storyboard and narration script, written together |
|
||||
| 5 | **VO + Timing** | `narration.wav` + `transcript.json` | TTS audio with word-level timestamps |
|
||||
| 6 | **Build** | `compositions/*.html` | Animated HTML compositions, one per beat |
|
||||
| 7 | **Validate** | Snapshot PNGs + `lint`/`validate` pass | Visual verification and runtime checks before delivery |
|
||||
|
||||
<Tip>
|
||||
Not every project uses every step. A no-narration brand reel skips Step 5; a hand-authored composition skips Steps 1-2. But the order matters: scene durations come from narration, animation choices come from the storyboard, and the storyboard depends on the design reference. Skip a step only when you don't need its artifact downstream.
|
||||
@@ -76,36 +76,35 @@ For sources that aren't websites (PDFs, decks, CSVs, notes), capture isn't a lit
|
||||
|
||||
`DESIGN.md` is the brand cheat sheet. It encodes the visual identity factually so every downstream decision can reference exact colors, fonts, and components instead of inventing them. It's a reference document, not a creative plan. The creative work happens in the storyboard.
|
||||
|
||||
A typical `DESIGN.md` has six sections:
|
||||
A typical `DESIGN.md` has five sections:
|
||||
|
||||
| Section | What it captures |
|
||||
|---------|------------------|
|
||||
| **Overview** | 3-4 sentences describing layout patterns, color strategy, typography tone |
|
||||
| **Colors** | 5-10 HEX values with semantic roles (primary surface, accent warm, etc.) |
|
||||
| **Typography** | Font families with weights, roles, and distinctive usage |
|
||||
| **Components** | Patterns the brand uses: bento grids, logo walls, gradient meshes |
|
||||
| **Imagery** | Asset categories and how the brand uses them |
|
||||
| **Do's and Don'ts** | Hard rules: "white backgrounds, never dark", "no drop shadows" |
|
||||
| **Visual Theme** | 3-5 sentences describing the brand's visual personality — dark/light, contrast, mood, what makes it distinctive |
|
||||
| **Quick Reference** | Colors (8-12 HEX values with semantic roles and WCAG contrast ratios) and fonts (families, weights, roles, file paths) |
|
||||
| **Component Stylings** | Exact CSS-level specs for 6-12 components the brand uses: buttons, cards, containers, distinctive UI patterns |
|
||||
| **Spacing & Layout** | Base spacing unit, scale with usage, max-width / grid, and breakpoint strategy |
|
||||
| **Iteration Guide** | Do's and don'ts, common failure modes, and rules for modifying the design in later steps |
|
||||
|
||||
`DESIGN.md` is also the input format for [Open Design](/guides/open-design) and [Claude Design](/guides/claude-design); both produce a `DESIGN.md` you can drop into a Hyperframes project.
|
||||
|
||||
**Gate:** `DESIGN.md` exists with all six sections filled in from real captured data (or chosen deliberately for greenfield projects).
|
||||
**Gate:** `DESIGN.md` exists with all five sections filled in from real captured data (or chosen deliberately for greenfield projects).
|
||||
|
||||
## Step 3: Script
|
||||
## Step 3: Strategy & Messaging
|
||||
|
||||
**Output:** `SCRIPT.md` in the project root
|
||||
**Output:** Alignment on video type, duration, style, and — critically — the ONE message and narrative arc
|
||||
|
||||
`SCRIPT.md` is the narration backbone. Scene durations come from the narration, not from guessing, so write the script before the storyboard and time beats to spoken words.
|
||||
Before any creative decisions, align with the user on the story this video must tell. Parse the user's prompt first — they probably already gave you the video type and style. Only ask about things they didn't specify. If the prompt is detailed enough, confirm the direction in one message and move to Step 4.
|
||||
|
||||
A typical structure: **hook** (one sentence that earns attention), **story** (what the product or topic is), **proof** (numbers, components, customers), **CTA** (one clear action). Reference real features, real stats, and real components from `capture/extracted/visible-text.txt`. Don't invent claims the source doesn't support.
|
||||
The questions to resolve: what type of video (social ad, product demo, brand reel, etc.), what style and energy, what's the ONE thing this video must communicate, what narrative arc serves that message, and whether narration is wanted.
|
||||
|
||||
For videos without narration (brand reels, music-driven teasers), `SCRIPT.md` becomes a per-beat copy plan instead: the on-screen text and headlines, with timing notes.
|
||||
**Gate:** Video type, duration, format, and the message and narrative arc are locked. Without those, Step 4 can't write a concept-first storyboard.
|
||||
|
||||
**Gate:** `SCRIPT.md` exists in the project root.
|
||||
## Step 4: Storyboard + Script
|
||||
|
||||
## Step 4: Storyboard
|
||||
**Outputs:** `STORYBOARD.md` + `SCRIPT.md` in the project root
|
||||
|
||||
**Output:** `STORYBOARD.md` in the project root
|
||||
Write the storyboard concept-first: message → narrative arc → beats that serve the arc → techniques per beat → brand accents pass at the end. Then write the narration script to match. The storyboard and script are written together — the storyboard drives the script, not the other way around.
|
||||
|
||||
`STORYBOARD.md` tells the engineer (human or agent) exactly what to build for each beat: mood, camera, animations, transitions, assets, depth layers, sound effects. It's where the creative choices get pinned down.
|
||||
|
||||
@@ -121,9 +120,9 @@ Each beat in `STORYBOARD.md` typically covers:
|
||||
| Transitions | How this beat enters from the previous one and exits to the next |
|
||||
| SFX | Short, specific sound effects (e.g. _"woosh on logo entry, soft tick on counter"_) |
|
||||
|
||||
The storyboard typically opens with a global-direction block: format, voiceover direction, style basis, and guardrails that apply to every beat.
|
||||
The storyboard typically opens with a global-direction block: format, voiceover direction, style basis, and guardrails that apply to every beat. `SCRIPT.md` contains the narration backbone: **hook** (one sentence that earns attention), **story** (what the product or topic is), **proof** (numbers, components, customers), **CTA** (one clear action). For videos without narration, `SCRIPT.md` becomes a per-beat copy plan with on-screen text and timing notes.
|
||||
|
||||
**Gate:** `STORYBOARD.md` exists with beat-by-beat direction and an asset audit that names every file used.
|
||||
**Gate:** `STORYBOARD.md` + `SCRIPT.md` exist with beat-by-beat direction, an asset audit that names every file used, and user approval of the plan.
|
||||
|
||||
## Step 5: VO and timing
|
||||
|
||||
@@ -142,7 +141,7 @@ npx hyperframes transcribe narration.wav
|
||||
| `narration.txt` | The exact spoken text with pronunciation substitutions applied (`API` → `A P I`, `$2T` → `two trillion`). Distinct from `SCRIPT.md` so you can regenerate the audio later with a different voice without redoing the substitutions. |
|
||||
| `transcript.json`| `[{ text, start, end }]` for every word. Every later step reads this for timing. |
|
||||
|
||||
Hyperframes ships multiple TTS adapters (Kokoro, ElevenLabs, HeyGen); see [`/hyperframes-media`](/guides/prompting) for the skill that picks one. After generating audio, update `STORYBOARD.md` with the real beat boundaries from `transcript.json`.
|
||||
Hyperframes ships multiple TTS adapters (Kokoro, ElevenLabs, HeyGen); see [`/media-use`](/guides/prompting) for the skill that picks one. After generating audio, update `STORYBOARD.md` with the real beat boundaries from `transcript.json`.
|
||||
|
||||
**Gate:** `narration.wav`, `narration.txt`, and `transcript.json` exist. `STORYBOARD.md` beat timings reference real timestamps, not estimates.
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ The installer shows a picker. Select the **core skills** below — every project
|
||||
| `/hyperframes-core` | Composition contract — HTML structure, `data-*` attributes, clips, tracks |
|
||||
| `/hyperframes-animation`| All animation — motion rules, scene blueprints, transitions, and the runtime adapters (GSAP, Lottie, Three.js, Anime.js, CSS, WAAPI, TypeGPU) |
|
||||
| `/hyperframes-creative` | Creative direction — design spec, palettes, typography, narration, beats |
|
||||
| `/hyperframes-cli` | Dev-loop CLI — `init`, `lint`, `inspect`, `preview`, `render`, `doctor` |
|
||||
| `/hyperframes-media` | Asset preprocessing — `tts`, `transcribe`, `remove-background` |
|
||||
| `/hyperframes-cli` | Dev-loop CLI — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `doctor` |
|
||||
| `/media-use` | Asset preprocessing — `tts`, `transcribe`, `remove-background` |
|
||||
| `/hyperframes-registry` | Block and component installation via `hyperframes add` |
|
||||
| `/general-video` | The general authoring workflow — fallback for any video that doesn't match a specific workflow below |
|
||||
|
||||
@@ -39,6 +39,8 @@ The installer shows a picker. Select the **core skills** below — every project
|
||||
| `/embedded-captions` | An existing talking-head video → the same footage with captions / subtitles |
|
||||
| `/talking-head-recut` | An existing talking-head video → footage packaged with designed graphic cards |
|
||||
| `/motion-graphics` | A short, unnarrated, design-led motion graphic (logo sting, kinetic type, stat / chart) |
|
||||
| `/music-to-video` | A music track (audio file or video) → a beat-synced video (lyric, slideshow, or kinetic promo) |
|
||||
| `/slideshow` | A presentation / pitch deck / interactive deck — discrete slides, fragment reveals, branching |
|
||||
| `/remotion-to-hyperframes` | Port an existing Remotion (React) composition to HyperFrames HTML |
|
||||
|
||||
<Tip>
|
||||
@@ -119,7 +121,7 @@ Describe how motion should *feel* and the agent picks the matching GSAP ease:
|
||||
|
||||
| Say this | Agent uses | Feels like |
|
||||
| ----------- | ---------------- | ------------------------------ |
|
||||
| smooth | `power2.out` | Natural deceleration |
|
||||
| smooth | `sine` / `power1`| Natural deceleration |
|
||||
| snappy | `power4.out` | Quick and decisive |
|
||||
| bouncy | `back.out` | Overshoots then settles |
|
||||
| springy | `elastic.out` | Oscillates into place |
|
||||
@@ -182,7 +184,7 @@ Map audio frequency bands to visual properties. The agent uses these defaults:
|
||||
| Bass | `scale` | Pulse on the beat |
|
||||
| Treble | `glow` | Shimmer intensity |
|
||||
| Amplitude | `opacity` | Breathing |
|
||||
| Mids | `shape` | Morphing |
|
||||
| Mids | `borderRadius` | Shape morphing |
|
||||
|
||||
```
|
||||
"Make the text pulse with the beat"
|
||||
@@ -203,8 +205,8 @@ Hand-drawn emphasis effects for text:
|
||||
| `highlight` | Marker sweep | Key phrases |
|
||||
| `circle` | Hand-drawn ellipse | Single words |
|
||||
| `burst` | Radiating lines | Hype moments |
|
||||
| `scribble` | Chaotic scratch | Crossing out |
|
||||
| `sketchout` | Rectangle outline | Callouts |
|
||||
| `scribble` | Chaotic scratch | Rough emphasis|
|
||||
| `sketchout` | Cross-hatch lines | Crossing out |
|
||||
|
||||
```
|
||||
"Add a marker highlight sweep on 'revolutionary'"
|
||||
@@ -214,9 +216,9 @@ Hand-drawn emphasis effects for text:
|
||||
|
||||
### Text-to-speech voices
|
||||
|
||||
TTS runs locally via Kokoro (no API key needed). Describe the content and the agent picks a voice, or request one directly:
|
||||
HyperFrames supports three TTS providers: **HeyGen** (Starfish voices, requires sign-in via `npx hyperframes auth`), **ElevenLabs** (requires API key), and **Kokoro** (free, runs locally, no API key needed). The agent asks which provider to use — or picks automatically in autonomous mode. Describe the content and the agent picks a voice, or request one directly:
|
||||
|
||||
| Content type | Recommended voices |
|
||||
| Content type | Kokoro voices |
|
||||
| ------------- | -------------------------- |
|
||||
| Product demo | `af_heart`, `af_nova` |
|
||||
| Tutorial | `am_adam`, `bf_emma` |
|
||||
@@ -226,6 +228,7 @@ TTS runs locally via Kokoro (no API key needed). Describe the content and the ag
|
||||
"Generate narration for this script"
|
||||
"Create voiceover with a professional female voice"
|
||||
"Add TTS with British male voice at 1.1x speed"
|
||||
"Use HeyGen TTS for this narration"
|
||||
```
|
||||
|
||||
### Rendering quality
|
||||
|
||||
@@ -117,13 +117,13 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, WebM
|
||||
|------|--------|---------|-------------|
|
||||
| `--output` | path | `renders/<name>.mp4` | Output file path |
|
||||
| `--format` | mp4, mov, webm, gif, png-sequence | mp4 | Output format (see [Transparent Video](#transparent-video) below) |
|
||||
| `--fps` | 24, 30, 60 | 30 | Frames per second |
|
||||
| `--fps` | 1-240 or rational (e.g. `30000/1001`) | 30 | Frames per second |
|
||||
| `--gif-loop` | 0-65535 | 0 | GIF loop count. `0` loops forever |
|
||||
| `--quality` | draft, standard, high | standard | Encoding quality preset |
|
||||
| `--crf` | 0–51 | — | Override CRF (lower = higher quality). Cannot combine with `--video-bitrate` |
|
||||
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target bitrate encoding. Cannot combine with `--crf` |
|
||||
| `--video-frame-format` | auto, jpg, png | auto | Source video frame extraction format. Use `png` for UI recordings, screen captures, and color-sensitive source videos |
|
||||
| `--workers` | 1-8 or `auto` | auto | Parallel render workers (see [Workers](#workers) below) |
|
||||
| `--workers` | 1-24 or `auto` | auto | Parallel render workers (see [Workers](#workers) below) |
|
||||
| `--max-concurrent-renders` | 1-10 | 2 | Max simultaneous renders via the producer server (see [Concurrent Renders](#concurrent-renders) below) |
|
||||
| `--batch` | path | — | JSON array of variable rows (or `{ "rows": [...] }`), rendering one output per row |
|
||||
| `--batch-concurrency` | integer | 1 | Maximum batch rows to render at once |
|
||||
@@ -197,16 +197,16 @@ Each render worker launches a **separate Chrome browser process** to capture fra
|
||||
|
||||
### Default behavior
|
||||
|
||||
By default, Hyperframes uses **half of your CPU cores, capped at 4**:
|
||||
By default, Hyperframes uses **CPU cores minus 2** (reserving headroom for FFmpeg encoding and your other applications):
|
||||
|
||||
| Machine | CPU cores | Default workers |
|
||||
|---------|-----------|----------------|
|
||||
| MacBook Air (M1) | 8 | 4 |
|
||||
| MacBook Pro (M3) | 12 | 4 (capped) |
|
||||
| MacBook Air (M1) | 8 | 6 |
|
||||
| MacBook Pro (M3) | 12 | 10 |
|
||||
| 4-core laptop | 4 | 2 |
|
||||
| 2-core VM | 2 | 1 |
|
||||
|
||||
This is intentionally conservative. Each worker spawns its own Chrome process, so the per-worker overhead is significant. Fewer workers avoids resource contention with FFmpeg encoding and your other applications.
|
||||
Each worker spawns its own Chrome process (~256 MB RAM), so the per-worker overhead is significant. The maximum is 24 workers (hard ceiling).
|
||||
|
||||
### Choosing a worker count
|
||||
|
||||
@@ -218,7 +218,7 @@ npx hyperframes render --workers 1 --output output.mp4
|
||||
npx hyperframes render --workers auto --output output.mp4
|
||||
|
||||
# Maximum parallelism (use with caution on laptops)
|
||||
npx hyperframes render --workers 8 --output output.mp4
|
||||
npx hyperframes render --workers 24 --output output.mp4
|
||||
```
|
||||
|
||||
<Tip>
|
||||
@@ -389,6 +389,17 @@ Only the visible elements (cards, text, images) will appear in the final video.
|
||||
- **In a video editor:** Import the MOV file and place it on a track above other footage. Transparent areas should show the footage below.
|
||||
- **Online tool:** Use [rotato.app/tools/transparent-video](https://rotato.app/tools/transparent-video) to verify your MOV or WebM has working transparency.
|
||||
|
||||
<Note>
|
||||
**`ffprobe` reports a transparent WebM as `yuv420p`, not `yuva420p`.** This is expected and does not mean the alpha is missing. VP9 stores its alpha plane in a Matroska `BlockAdditional` sidecar, not in the primary stream's pixel format, so `ffprobe` reports the primary stream as `pix_fmt=yuv420p` even when the file genuinely carries alpha. First check that the WebM declares the alpha sidecar, then force the VP9 library decoder to verify per-pixel alpha:
|
||||
|
||||
```bash
|
||||
ffprobe -v error -show_streams out.webm | grep -i alpha_mode # ALPHA_MODE=1 or alpha_mode=1
|
||||
ffmpeg -c:v libvpx-vp9 -i out.webm -frames:v 1 -f rawvideo -pix_fmt rgba frame.raw
|
||||
```
|
||||
|
||||
A fully transparent corner pixel in `frame.raw` reads `00 00 00 00`. MOV (ProRes 4444) and `png-sequence` report their alpha directly, so this caveat is WebM-only.
|
||||
</Note>
|
||||
|
||||
## Tips
|
||||
|
||||
<Tip>
|
||||
|
||||
+13
-12
@@ -7,36 +7,36 @@ HyperFrames ships AI agent skills via [vercel-labs/skills](https://github.com/ve
|
||||
|
||||
The skills split into three groups:
|
||||
|
||||
- **Router** — the entry skill that picks a workflow for any "make me a video" request.
|
||||
- **Creation workflows** — one per input shape (URL, PR, music track, captions, etc.). Read by the router; you can also invoke directly if you already know which one fits.
|
||||
- **Domain skills** — atomic capabilities (animation, media, CLI, registry) the workflows compose against. Load one when you need that specific layer.
|
||||
- **Router** — the entry skill that picks a workflow for any "make me a…" request — usually a video, but also a navigable deck (`/slideshow`) or a composition port (`/remotion-to-hyperframes`).
|
||||
- **Creation workflows** — one per input shape (URL, PR, music track, captions, etc.). Each owns its task end-to-end: project setup, gated steps, and the final deliverable. Read by the router; you can also invoke directly if you already know which one fits.
|
||||
- **Domain skills** — atomic capabilities (animation, media, CLI, registry) the workflows compose against; they never own the end-to-end task. Load one when you need that specific layer.
|
||||
|
||||
## Install
|
||||
|
||||
<Steps>
|
||||
<Step title="Pick what to install (interactive picker)">
|
||||
```bash
|
||||
npx skills add heygen-com/hyperframes
|
||||
npx skills add heygen-com/hyperframes --full-depth
|
||||
```
|
||||
|
||||
Opens a picker so you can choose which skills to add. Works with [Claude Code](https://claude.ai/claude-code), [Cursor](https://cursor.sh), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex CLI](https://github.com/openai/codex), [GitHub Copilot CLI](/guides/copilot-cli), and [Google Antigravity](/guides/antigravity).
|
||||
Opens a picker so you can choose which skills to add. Keep `--full-depth`: it installs the current `main`. Without it, `skills add` fetches the skills.sh registry blob, which lags `main` by hours, so you may get an older copy of a skill. Works with [Claude Code](https://claude.ai/claude-code), [Cursor](https://cursor.sh), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex CLI](https://github.com/openai/codex), [GitHub Copilot CLI](/guides/copilot-cli), and [Google Antigravity](/guides/antigravity).
|
||||
</Step>
|
||||
<Step title="Or install all 19 at once (skip the picker)">
|
||||
<Step title="Or install everything at once (skip the picker)">
|
||||
```bash
|
||||
npx skills add heygen-com/hyperframes --all
|
||||
npx skills add heygen-com/hyperframes --all --full-depth
|
||||
```
|
||||
|
||||
Writes every skill to your project in one shot. Recommended when you want the full set without selecting from the picker.
|
||||
</Step>
|
||||
<Step title="Or install one skill at a time">
|
||||
```bash
|
||||
npx skills add heygen-com/hyperframes --skill <name>
|
||||
npx skills add heygen-com/hyperframes --skill <name> --full-depth
|
||||
```
|
||||
|
||||
Pass the bare skill name (no leading `/`) — e.g. `--skill hyperframes-animation`. Useful when you want a single capability without the full set.
|
||||
</Step>
|
||||
<Step title="Read /hyperframes first">
|
||||
Once installed, invoke `/hyperframes` in your agent. It's the capability map for everything below and routes "make me a video" intent to the right creation workflow based on your input.
|
||||
Once installed, invoke `/hyperframes` in your agent. It's the capability map for everything below and routes "make me a…" intent — a video, a deck, or a composition port — to the right creation workflow based on your input.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -78,11 +78,12 @@ Atomic capabilities the creation workflows compose against — pull one when you
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/hyperframes-core` | The composition contract — `data-*` timing attributes, `class="clip"`, tracks, sub-compositions, variables, framework-owned media playback, determinism rules. |
|
||||
| `/hyperframes-animation` | All animation knowledge — atomic motion rules, scene blueprints, transitions, runtime adapters (GSAP / Lottie / Three.js / Anime.js / CSS / WAAPI / TypeGPU). |
|
||||
| `/hyperframes-keyframes` | Seek-safe keyframe authoring across runtimes — GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, 3D depth — plus `hyperframes keyframes` diagnostics for rendered motion. |
|
||||
| `/hyperframes-creative` | Non-animation creative direction — `frame.md` / `design.md`, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns. |
|
||||
| `/hyperframes-media` | Audio + media — TTS voiceover, background music, sound effects, Whisper transcription, background removal, caption authoring (one shared audio engine). |
|
||||
| `/media-use` | Resolve any media need (BGM, SFX, image, icon) into a frozen local file + ledger record. One verb (`resolve`) over the HeyGen catalog with manifest tracking. |
|
||||
| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress`). |
|
||||
| `/media-use` | The media OS — resolve any media need (BGM, SFX, image, icon, voice) into a frozen local file + ledger record, generate via TTS/music/image models when the catalog misses, transcribe, caption, remove backgrounds, and reuse assets across projects. One shared audio engine + manifest tracking. |
|
||||
| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress / destroy / policies`). |
|
||||
| `/hyperframes-registry` | Install and wire registry blocks and components into compositions via `hyperframes add`. Authoring a new block or component to contribute upstream. |
|
||||
| `/figma` | Import Figma assets, tokens, components, and storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI) plus Motion animations (MCP) and shaders (MCP source / native export) into a composition. |
|
||||
|
||||
## Source of truth
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ If your issue is about a specific coding mistake (animations not working, video
|
||||
|
||||
```html index.html
|
||||
<div id="root" data-composition-id="my-video"
|
||||
data-start="0" data-width="1920" data-height="1080">
|
||||
data-width="1920" data-height="1080">
|
||||
<!-- elements here -->
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -29,7 +29,7 @@ Give your AI agent a URL and a creative direction. It captures the site, extract
|
||||
Create a 25-second product launch video from https://example.com. Bold, cinematic, dark theme energy.
|
||||
```
|
||||
|
||||
The agent loads the skill when they see a URL and a video request, and runs the full pipeline — capture, design, script, storyboard, voiceover, build, validate.
|
||||
The agent loads the skill when they see a URL and a video request, and runs the full pipeline — capture, design, strategy & messaging, storyboard + script, voiceover, build, validate.
|
||||
|
||||
<Note>
|
||||
Agents also trigger this skill automatically when they see a URL and a video request.
|
||||
@@ -65,12 +65,12 @@ The skill follows the [Hyperframes pipeline](/guides/pipeline): seven steps, eac
|
||||
| Step | Output | What happens |
|
||||
|------|--------|-------------|
|
||||
| **Capture** | `capture/` | Extract screenshots, design tokens, fonts, assets, animations |
|
||||
| **Design** | `DESIGN.md` | Brand reference — colors, typography, do's and don'ts |
|
||||
| **Script** | `SCRIPT.md` | Narration text with hook, story, proof, CTA |
|
||||
| **Storyboard** | `STORYBOARD.md` | Per-beat creative direction — mood, assets, animations, transitions |
|
||||
| **Design** | `DESIGN.md` | Brand reference — colors, typography, component stylings, spacing, iteration guide |
|
||||
| **Strategy & Messaging** | — | Align on video type, style, the ONE message, and narrative arc |
|
||||
| **Storyboard + Script** | `STORYBOARD.md` + `SCRIPT.md` | Concept-first storyboard and narration script, written together |
|
||||
| **VO + Timing** | `narration.wav` + `transcript.json` | TTS audio with word-level timestamps |
|
||||
| **Build** | `compositions/*.html` | Animated HTML compositions, one per beat |
|
||||
| **Validate** | Snapshot PNGs | Visual verification before delivery |
|
||||
| **Validate** | Snapshot PNGs + lint/validate pass | Visual verification and runtime checks before delivery |
|
||||
|
||||
See [the pipeline guide](/guides/pipeline) for a detailed walkthrough of each step, the contents of every generated file, and how to iterate without re-running the whole pipeline. The structure is useful for any Hyperframes project, not just website captures.
|
||||
|
||||
@@ -81,11 +81,10 @@ The prompt determines the format. Include a duration and creative direction:
|
||||
| Type | Duration | Example |
|
||||
|------|----------|---------|
|
||||
| Social ad | 10–15s | _"15-second Instagram reel. Energetic, fast cuts."_ |
|
||||
| Product launch | 20–30s | _"25-second product launch. Apple keynote energy."_ |
|
||||
| Product tour | 30–60s | _"45-second tour showing the top 3 features."_ |
|
||||
| Brand reel | 15–30s | _"20-second brand video. Celebrate the design."_ |
|
||||
| Feature announcement | 15–25s | _"Feature announcement highlighting the new AI agents."_ |
|
||||
| Teaser | 8–15s | _"10-second teaser. Super minimal. Just the hook."_ |
|
||||
| Product demo | 30–60s | _"45-second demo showing the top 3 features."_ |
|
||||
| Feature announcement | 15–30s | _"Feature announcement highlighting the new AI agents."_ |
|
||||
| Brand reel | 20–45s | _"30-second brand video. Celebrate the design."_ |
|
||||
| Launch teaser | 10–20s | _"12-second teaser. Super minimal. Just the hook."_ |
|
||||
|
||||
<Tip>
|
||||
Creative direction matters more than format. _"Playful, hand-crafted feel"_ or _"dark, developer-focused, show code"_ shapes the storyboard and drives every visual decision the agent makes.
|
||||
|
||||
+15
-5
@@ -667,8 +667,10 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
|
||||
| Flag | Values | Default | Description |
|
||||
|------|--------|---------|-------------|
|
||||
| `--output` | path | `renders/<name>.mp4` | Output file path |
|
||||
| `--format` | mp4, webm, mov, png-sequence | mp4 | Output format (WebM/MOV render with transparency; png-sequence writes a directory of RGBA PNGs) |
|
||||
| `--fps` | 24, 30, 60 | 30 | Frames per second |
|
||||
| `--composition, -c` | path | `index.html` | Render a specific composition file instead of `index.html` |
|
||||
| `--format` | mp4, webm, mov, gif, png-sequence | mp4 | Output format (WebM/MOV render with transparency; gif for inline embeds; png-sequence writes a directory of RGBA PNGs) |
|
||||
| `--fps` | 1-240 or rational (e.g. `30000/1001`) | 30 | Frames per second |
|
||||
| `--gif-loop` | 0-65535 | 0 | GIF loop count (`0` = loop forever). Only applies with `--format gif` |
|
||||
| `--quality` | draft, standard, high | standard | Encoding quality preset (drives CRF/bitrate) |
|
||||
| `--crf` | 0-51 | — | Override encoder CRF (lower = higher quality). Mutually exclusive with `--video-bitrate` |
|
||||
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target video bitrate. Mutually exclusive with `--crf` |
|
||||
@@ -676,7 +678,7 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
|
||||
| `--resolution` | landscape, portrait, landscape-4k, portrait-4k, square, square-4k (aliases: `1080p`, `4k`, `uhd`, `1080p-square`, `square-1080p`, `4k-square`) | — | Output resolution preset. Supersamples a smaller composition via Chrome `deviceScaleFactor` so the screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not supported with `--hdr`. See [4K Rendering](/guides/4k-rendering) |
|
||||
| `--hdr` | — | off | Force HDR output even if no HDR sources are detected. MP4 only. See [HDR Rendering](/guides/hdr) |
|
||||
| `--sdr` | — | off | Force SDR output even if HDR sources are detected |
|
||||
| `--workers` | 1-8 | 4 | Parallel render workers |
|
||||
| `--workers` | 1-24 or `auto` | auto | Parallel render workers (auto = CPU cores minus 2) |
|
||||
| `--low-memory-mode` / `--no-low-memory-mode` | — | auto (≤ 8 GB RAM) | Force the low-memory safe render profile on or off. Safe mode pins to 1 worker, uses screenshot capture, and skips auto-worker calibration so the pipeline doesn't launch multiple concurrent Chrome instances on constrained machines. Auto-detection reads **host** RAM (`os.totalmem()`), not cgroup/container limits — containerised or serverless callers (incl. `--docker`) should set `PRODUCER_LOW_MEMORY_MODE` explicitly. Env fallback `PRODUCER_LOW_MEMORY_MODE`. |
|
||||
| `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, AMF, VAAPI, QSV) |
|
||||
| `--browser-gpu` / `--no-browser-gpu` | — | on locally, off in Docker | Use or opt out of host GPU acceleration for local Chrome/WebGL capture |
|
||||
@@ -876,12 +878,20 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
|
||||
|
||||
# Rating with optional details
|
||||
npx hyperframes feedback --rating 3 --comment "render succeeded but GSAP timeline didn't animate"
|
||||
|
||||
# Also file a pre-filled GitHub issue with a published minimal repro (opt-in, consented)
|
||||
npx hyperframes feedback --rating 2 --comment "GSAP timeline froze on seek" --file-issue
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--rating` | Satisfaction score, 1–5 (required) |
|
||||
| `--comment` | Optional free-text details |
|
||||
| `--file-issue` | Also open a pre-filled GitHub issue with a published minimal repro (opt-in) |
|
||||
| `--dir` | Project directory to publish as the repro (default: current directory) |
|
||||
| `--yes` | Skip the publish + file-issue consent prompt (for scripts) |
|
||||
|
||||
With `--file-issue`, the CLI publishes a minimal repro to a public URL (with consent) and opens a pre-filled `bug` issue draft you review and submit yourself (no token or backend). See [Feedback Collection](/guides/feedback#filing-a-github-issue---file-issue).
|
||||
|
||||
This command is also available to AI agents after a render — see [Feedback Collection](/guides/feedback#agent-runtimes) for how agent detection and the automatic post-render hint work.
|
||||
|
||||
@@ -897,7 +907,7 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
|
||||
npx hyperframes telemetry status
|
||||
```
|
||||
|
||||
Telemetry collects command names, render performance, render checkpoint/error names, aggregate browser diagnostic counts, browser initialization duration and tween count, aggregate video extraction workload counts (such as extracted frame count and VFR preflight count), example choices, and system info — including a coarse environment fingerprint (OS, kernel string, CPU/memory shape, sandbox runtime such as gVisor or Docker, and the *name* of a coding agent driving the CLI when one is detected, e.g. `claude_code` / `codex` / `cursor`). The agent name is derived from the existence of well-known environment variables; their values are never read. Telemetry redacts local paths and URL query strings from render error/checkpoint messages and does **not** collect project names, video content, environment variable values, or personally identifiable information. Disable with `HYPERFRAMES_NO_TELEMETRY=1` or the command above.
|
||||
Telemetry collects command names, render performance, render checkpoint/error names, aggregate browser diagnostic counts, browser initialization duration and tween count, aggregate video extraction workload counts (such as extracted frame count and VFR preflight count), example choices, and system info — including a coarse environment fingerprint (OS, kernel string, CPU/memory shape, sandbox runtime such as gVisor or Docker, and the *name* of a coding agent driving the CLI when one is detected, e.g. `claude_code` / `codex` / `cursor`). The agent name is derived from the existence of well-known environment variables; their values are never read. Telemetry redacts local paths and URL query strings from render error/checkpoint messages and does **not** collect project names, video content, or environment variable values. It collects no personally identifiable information until you sign in: when you authenticate with `hyperframes auth login`, your HeyGen account email (or your username, if your account has no email) is linked to your usage so CLI activity can be associated with your account (and your prior anonymous usage is stitched to it). Nothing else personal is collected, and this only happens after you choose to sign in. Disable all telemetry with `HYPERFRAMES_NO_TELEMETRY=1` or the command above.
|
||||
|
||||
See [Feedback Collection](/guides/feedback) for how the periodic post-render prompt and Studio feedback bar work, what data they collect, and how to opt out.
|
||||
|
||||
@@ -922,7 +932,7 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
|
||||
| `--codex` | Install to Codex CLI (`~/.codex/skills/`) |
|
||||
| `--cursor` | Install to Cursor (`.cursor/skills/` in current project) |
|
||||
|
||||
Skills are fetched from GitHub: the `/hyperframes` entry skill (routes "make me a video" to a workflow), the composition contract and Tailwind v4 browser-runtime guidance (`/hyperframes-core`), all animation including the GSAP / Anime.js / CSS / Lottie / Three.js / WAAPI / TypeGPU runtime adapters (`/hyperframes-animation`), creative direction (`/hyperframes-creative`), media preprocessing (`/hyperframes-media`), registry block/component wiring (`/hyperframes-registry`), and the video workflows. The `init` command also offers to install skills automatically after scaffolding a project.
|
||||
Skills are fetched from GitHub: the `/hyperframes` entry skill (routes "make me a video" to a workflow), the composition contract and Tailwind v4 browser-runtime guidance (`/hyperframes-core`), all animation including the GSAP / Anime.js / CSS / Lottie / Three.js / WAAPI / TypeGPU runtime adapters (`/hyperframes-animation`), creative direction (`/hyperframes-creative`), media preprocessing (`/media-use`), registry block/component wiring (`/hyperframes-registry`), and the video workflows. The `init` command also offers to install skills automatically after scaffolding a project.
|
||||
|
||||
#### Troubleshooting: `fatal: active post-checkout hook found during git clone`
|
||||
|
||||
|
||||
+6
-13
@@ -62,11 +62,6 @@ import {
|
||||
isTextElement,
|
||||
isMediaElement,
|
||||
isCompositionElement,
|
||||
isStringVariable,
|
||||
isNumberVariable,
|
||||
isColorVariable,
|
||||
isBooleanVariable,
|
||||
isEnumVariable,
|
||||
} from '@hyperframes/core';
|
||||
|
||||
// Constants
|
||||
@@ -183,18 +178,16 @@ const result = validateCompositionHtml(html);
|
||||
|
||||
```typescript
|
||||
import {
|
||||
parseGsapScript,
|
||||
serializeGsapAnimations,
|
||||
updateAnimationInScript,
|
||||
addAnimationToScript,
|
||||
removeAnimationFromScript,
|
||||
getAnimationsForElement,
|
||||
getAnimationsForElementId,
|
||||
validateCompositionGsap,
|
||||
keyframesToGsapAnimations,
|
||||
gsapAnimationsToKeyframes,
|
||||
SUPPORTED_PROPS, // animatable properties
|
||||
SUPPORTED_EASES, // available easing functions
|
||||
} from '@hyperframes/core';
|
||||
|
||||
// GSAP parsing, mutation, and constants live in @hyperframes/parsers:
|
||||
import { parseGsapScript, SUPPORTED_PROPS, SUPPORTED_EASES } from '@hyperframes/parsers/gsap-parser';
|
||||
import { updateAnimationInScript, addAnimationToScript, removeAnimationFromScript } from '@hyperframes/parsers/gsap-writer-acorn';
|
||||
import type { GsapAnimation, GsapMethod, ParsedGsap } from '@hyperframes/core';
|
||||
|
||||
// Parse GSAP script into structured animations
|
||||
@@ -258,7 +251,7 @@ import { lintHyperframeHtml, lintMediaUrls } from '@hyperframes/core/lint';
|
||||
import type {
|
||||
HyperframeLintResult,
|
||||
HyperframeLintFinding,
|
||||
HyperframeLintSeverity, // "error" | "warning"
|
||||
HyperframeLintSeverity, // "error" | "warning" | "info"
|
||||
HyperframeLinterOptions,
|
||||
} from '@hyperframes/core/lint';
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ The engine implements a **seek-and-capture** loop that is fundamentally differen
|
||||
Your HTML composition is loaded into a browser page. The Hyperframes runtime is injected to manage timeline seeking.
|
||||
</Step>
|
||||
<Step title="Seek to each frame">
|
||||
For every frame in the video (e.g., 900 frames for a 30-second video at 30fps), the engine calls `renderSeek(time)` to advance the composition to the exact timestamp. No wall clock is involved — each frame is independently positioned.
|
||||
For every frame in the video (e.g., 900 frames for a 30-second video at 30fps), the engine calls `window.__hf.seek(time)` to advance the composition to the exact timestamp. No wall clock is involved — each frame is independently positioned.
|
||||
</Step>
|
||||
<Step title="Capture via BeginFrame">
|
||||
Chrome's `HeadlessExperimental.beginFrame` API captures the compositor output as a pixel buffer. This produces pixel-perfect frames without any screen recording artifacts.
|
||||
@@ -95,14 +95,16 @@ import {
|
||||
closeCaptureSession,
|
||||
} from '@hyperframes/engine';
|
||||
|
||||
// 1. Create a capture session
|
||||
const session = await createCaptureSession({ fps: { num: 30, den: 1 }, width: 1920, height: 1080 });
|
||||
// 1. Create a capture session (serverUrl, outputDir, options)
|
||||
const session = await createCaptureSession(serverUrl, outputDir, {
|
||||
fps: { num: 30, den: 1 }, width: 1920, height: 1080,
|
||||
});
|
||||
|
||||
// 2. Initialize with a composition
|
||||
await initializeSession(session, './my-video/index.html');
|
||||
// 2. Initialize the session
|
||||
await initializeSession(session);
|
||||
|
||||
// 3. Get the total duration
|
||||
const duration = getCompositionDuration(session);
|
||||
// 3. Get the total duration (async)
|
||||
const duration = await getCompositionDuration(session);
|
||||
|
||||
// 4. Capture frames
|
||||
const totalFrames = Math.ceil(duration * 30);
|
||||
@@ -227,7 +229,7 @@ const frames = await extractAllVideoFrames(videoPath, { fps: 30 });
|
||||
|
||||
// Create a lookup table for fast frame access
|
||||
const lookup = createFrameLookupTable(frames);
|
||||
const frame = lookup.getFrameAtTime(5.0);
|
||||
const frame = lookup.getFrame('video-1', 5.0);
|
||||
```
|
||||
|
||||
### Audio Processing
|
||||
@@ -384,7 +386,7 @@ For more on how this enables deterministic output, see [Deterministic Rendering]
|
||||
|
||||
### Seek Contract
|
||||
|
||||
The engine relies on the Hyperframes runtime's `renderSeek(time)` function. When called, `renderSeek`:
|
||||
The engine relies on the Hyperframes runtime's `window.__hf.seek(time)` function. When called, `seek`:
|
||||
|
||||
1. Pauses all GSAP timelines
|
||||
2. Seeks every timeline to the exact timestamp
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
import type {
|
||||
HyperframeLintResult,
|
||||
HyperframeLintFinding,
|
||||
HyperframeLintSeverity, // "error" | "warning"
|
||||
HyperframeLintSeverity, // "error" | "warning" | "info"
|
||||
HyperframeLinterOptions,
|
||||
ProjectLintResult,
|
||||
} from '@hyperframes/lint';
|
||||
@@ -74,7 +74,7 @@ const result: ProjectLintResult = await lintProject('./my-composition');
|
||||
// result.totalErrors, result.totalWarnings, result.results[]
|
||||
// each result entry: { file, result: HyperframeLintResult }
|
||||
|
||||
if (shouldBlockRender(result)) {
|
||||
if (shouldBlockRender(false, false, result.totalErrors, result.totalWarnings)) {
|
||||
throw new Error(`Lint found ${result.totalErrors} blocking error(s)`);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -26,6 +26,7 @@ npm install @hyperframes/parsers
|
||||
| Import | Description |
|
||||
|--------|-------------|
|
||||
| `@hyperframes/parsers` | HTML parser, GSAP serialize/validate helpers, hf-ids, shared types |
|
||||
| `@hyperframes/parsers/gsap-parser` | GSAP parser exports, `parseGsapScript`, `SUPPORTED_PROPS`, `SUPPORTED_EASES` |
|
||||
| `@hyperframes/parsers/gsap-parser-acorn` | Acorn-based GSAP parser (browser-safe, read path) |
|
||||
| `@hyperframes/parsers/gsap-writer-acorn` | Acorn-based GSAP writer (mutation helpers) |
|
||||
| `@hyperframes/parsers/gsap-parser-recast` | Recast-based GSAP parser/writer (legacy implementation) |
|
||||
@@ -35,6 +36,7 @@ npm install @hyperframes/parsers
|
||||
| `@hyperframes/parsers/slideshow` | Slideshow manifest parser (`parseSlideshowManifest`, `resolveSlideshow`) |
|
||||
| `@hyperframes/parsers/composition` | Pure, browser-safe composition primitives (data types, font aliases, URL helper) |
|
||||
| `@hyperframes/parsers/asset-paths` | Node-only asset-path rewriting helpers (`rewriteAssetPath`, …) |
|
||||
| `@hyperframes/parsers/sub-composition-validity` | Sub-composition validation utilities |
|
||||
|
||||
<Info>
|
||||
The package ships subpath entries so consumers tree-shake to what they use — importing `@hyperframes/parsers/hf-ids` (a couple KB) does **not** pull in the GSAP AST machinery (recast/babel/acorn).
|
||||
|
||||
@@ -63,7 +63,7 @@ import '@hyperframes/player';
|
||||
| `controls` | boolean | false | Show playback controls overlay |
|
||||
| `autoplay` | boolean | false | Start playing on load |
|
||||
| `loop` | boolean | false | Loop playback |
|
||||
| `muted` | boolean | true | Mute audio (required for autoplay in most browsers) |
|
||||
| `muted` | boolean | false | Mute audio (set to `true` for autoplay in most browsers) |
|
||||
| `poster` | string | — | Image URL to show before first play |
|
||||
| `playback-rate` | number | 1 | Playback speed multiplier |
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ import { createRenderJob } from '@hyperframes/producer';
|
||||
const job = createRenderJob({
|
||||
fps: 30, // integer, or { num: 30000, den: 1001 } for NTSC
|
||||
quality: 'standard', // 'draft', 'standard', or 'high'
|
||||
format: 'mp4', // 'mp4', 'webm', 'mov', or 'png-sequence'
|
||||
workers: 4, // Parallel render workers (1-8)
|
||||
format: 'mp4', // 'mp4', 'webm', 'mov', 'gif', or 'png-sequence'
|
||||
workers: 4, // Parallel render workers (1-24, or omit for auto)
|
||||
useGpu: false, // GPU-accelerated encoding
|
||||
debug: false, // Debug logging
|
||||
});
|
||||
@@ -104,20 +104,20 @@ When `format: 'webm'`:
|
||||
|
||||
#### HDR Output
|
||||
|
||||
Set `hdr: true` to enable HDR detection. The producer probes every video and image source for BT.2020 / PQ / HLG color tagging — if any HDR source is found, the output uses H.265 10-bit BT.2020 with HDR10 static metadata. SDR-only compositions are unaffected.
|
||||
Set `hdrMode` to control HDR behavior. The producer probes every video and image source for BT.2020 / PQ / HLG color tagging — if any HDR source is found and the mode allows it, the output uses H.265 10-bit BT.2020 with HDR10 static metadata. SDR-only compositions are unaffected.
|
||||
|
||||
```typescript
|
||||
const job = createRenderJob({
|
||||
fps: 30,
|
||||
quality: 'standard',
|
||||
format: 'mp4',
|
||||
hdr: true,
|
||||
hdrMode: 'auto', // 'auto' | 'force-hdr' | 'force-sdr'
|
||||
});
|
||||
|
||||
await executeRenderJob(job, './my-video', './output.mp4');
|
||||
```
|
||||
|
||||
When `hdr: true`:
|
||||
When `hdrMode` is `'auto'` or `'force-hdr'`:
|
||||
- Sources are probed via `ffprobe`; PQ takes precedence over HLG when both are present
|
||||
- HDR videos and images are extracted as 16-bit linear-light pixels and composited natively
|
||||
- SDR DOM overlays are converted from sRGB → BT.2020 before being layered on top
|
||||
|
||||
@@ -29,6 +29,10 @@ npm install @hyperframes/sdk
|
||||
The SDK is the right layer for product integrations and agents that need structured edits. The CLI and Studio are user-facing tools built around the same composition format; the SDK is the editing engine you embed behind your own UI or automation.
|
||||
</Tip>
|
||||
|
||||
<Note>
|
||||
This page is the package overview. For the full API — guides and a complete reference for every method, operation, type, and adapter — see the [**SDK**](/sdk/overview) tab.
|
||||
</Note>
|
||||
|
||||
## Package Exports
|
||||
|
||||
| Import | Description |
|
||||
|
||||
@@ -29,6 +29,7 @@ npm install @hyperframes/studio-server
|
||||
| Import | Description |
|
||||
|--------|-------------|
|
||||
| `@hyperframes/studio-server` | `createStudioApi`, helpers, types |
|
||||
| `@hyperframes/studio-server/source-mutation` | Source mutation utilities |
|
||||
| `@hyperframes/studio-server/screenshot-clip` | Element screenshot-clip geometry |
|
||||
| `@hyperframes/studio-server/manual-edits-render-script` | Manual-edits render body script |
|
||||
| `@hyperframes/studio-server/studio-motion-render-script` | Studio motion render body script |
|
||||
@@ -49,6 +50,8 @@ const adapter: StudioApiAdapter = {
|
||||
bundle: async (projectDir) => bundleToSingleHtml(projectDir),
|
||||
lint: (html, opts) => lintHyperframeHtml(html, opts),
|
||||
runtimeUrl: '/hyperframe-runtime.js',
|
||||
rendersDir: (project) => join(project.dir, 'renders'),
|
||||
startRender: async (opts) => startRenderJob(opts),
|
||||
};
|
||||
|
||||
const api = createStudioApi(adapter); // → Hono app
|
||||
|
||||
@@ -54,7 +54,7 @@ The studio has two entry points:
|
||||
| `@hyperframes/studio` | React components, hooks, and types |
|
||||
| `@hyperframes/studio/tailwind-preset` | Tailwind CSS preset for studio styling |
|
||||
|
||||
Peer dependencies: `react` (18 or 19), `react-dom` (18 or 19), `zustand` (4 or 5).
|
||||
Peer dependencies: `react` (19), `react-dom` (19), `zustand` (4 or 5).
|
||||
|
||||
## Components
|
||||
|
||||
@@ -83,10 +83,8 @@ import {
|
||||
Player,
|
||||
PlayerControls,
|
||||
Timeline,
|
||||
PreviewPanel,
|
||||
AgentActivityTrack,
|
||||
} from '@hyperframes/studio';
|
||||
import type { AgentActivity, TimelineElement, ActiveEdits } from '@hyperframes/studio';
|
||||
import type { TimelineElement } from '@hyperframes/studio';
|
||||
|
||||
// Embed the preview player
|
||||
<Player />
|
||||
@@ -96,12 +94,6 @@ import type { AgentActivity, TimelineElement, ActiveEdits } from '@hyperframes/s
|
||||
|
||||
// Timeline editor with scrubber
|
||||
<Timeline />
|
||||
|
||||
// Preview display area
|
||||
<PreviewPanel />
|
||||
|
||||
// Activity visualization track (for agent workflows)
|
||||
<AgentActivityTrack activities={activities} />
|
||||
```
|
||||
|
||||
### Editor Components
|
||||
@@ -138,7 +130,8 @@ Manages player state and playback control:
|
||||
import { useTimelinePlayer } from '@hyperframes/studio';
|
||||
|
||||
const player = useTimelinePlayer();
|
||||
// player.play(), player.pause(), player.seek(time), player.stepForward(), player.stepBackward()
|
||||
// player.play(), player.pause(), player.togglePlay(), player.seek(time)
|
||||
// player.refreshPlayer(), player.saveSeekPosition(), player.resetPlayer()
|
||||
```
|
||||
|
||||
### `usePlayerStore`
|
||||
@@ -155,17 +148,6 @@ const store = usePlayerStore();
|
||||
const display = formatTime(liveTime.current);
|
||||
```
|
||||
|
||||
### `useCodeEditor`
|
||||
|
||||
Code editor state and editing functions:
|
||||
|
||||
```typescript
|
||||
import { useCodeEditor } from '@hyperframes/studio';
|
||||
|
||||
const editor = useCodeEditor();
|
||||
// editor.code, editor.setCode(), editor.diff, editor.onChange()
|
||||
```
|
||||
|
||||
### `useElementPicker`
|
||||
|
||||
Element selection from the preview:
|
||||
@@ -173,8 +155,10 @@ Element selection from the preview:
|
||||
```typescript
|
||||
import { useElementPicker } from '@hyperframes/studio';
|
||||
|
||||
const picker = useElementPicker();
|
||||
// picker.selectedElement, picker.selectElement(id), picker.clearSelection()
|
||||
const picker = useElementPicker(iframeRef);
|
||||
// picker.isPickMode, picker.pickedElement
|
||||
// picker.enablePick(), picker.disablePick(), picker.clearPick()
|
||||
// picker.setStyle(prop, value), picker.setDataAttr(name, value), picker.setTextContent(text)
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
+1
-1
@@ -22,7 +22,7 @@ The installer shows a picker. Select the **core set** every project needs — th
|
||||
| `/hyperframes-animation` | All animation + the GSAP / Lottie / Three.js / Anime.js / CSS / WAAPI / TypeGPU runtime adapters |
|
||||
| `/hyperframes-creative` | Design direction — palettes, typography, narration, beat planning |
|
||||
| `/hyperframes-cli` | Dev-loop CLI — init, lint, preview, render, doctor |
|
||||
| `/hyperframes-media` | Asset preprocessing — TTS, transcription, background removal |
|
||||
| `/media-use` | Asset preprocessing — TTS, transcription, background removal |
|
||||
| `/hyperframes-registry` | Install catalog blocks and components |
|
||||
| `/general-video` | The general authoring workflow — the fallback for any video that doesn't match a specific workflow |
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
title: "Canvas & Preview Integration"
|
||||
description: "Connect a same-origin composition iframe to the SDK for hit-testing, draft preview, and selection."
|
||||
---
|
||||
|
||||
The SDK's `PreviewAdapter` interface decouples the editing model from the visual surface. For browser-based editors, `createIframePreviewAdapter` bridges the SDK to a same-origin `<iframe>` containing the composition, giving you synchronous hit-testing, 60fps drag preview, and selection management — all without touching the model until the user commits.
|
||||
|
||||
<Note>
|
||||
The iframe must be same-origin (e.g. `srcdoc` or a `blob:` URL). Cross-origin iframe access throws a `DOMException`; the adapter does not guard this, so enforcing same-origin is the caller's responsibility.
|
||||
</Note>
|
||||
|
||||
## Embedding the composition
|
||||
|
||||
Render the composition HTML into a same-origin `<iframe>` in your editor shell, then pass that element plus a dispatch callback to `createIframePreviewAdapter`:
|
||||
|
||||
```typescript
|
||||
import { openComposition, createIframePreviewAdapter } from "@hyperframes/sdk";
|
||||
|
||||
// Assume `compositionHtml` is the composition's source HTML string.
|
||||
const iframe = document.querySelector<HTMLIFrameElement>("#composition-frame")!;
|
||||
|
||||
// Build the adapter first so you can pass it to openComposition.
|
||||
// The dispatch callback is called by commitPreview() after a drag completes.
|
||||
const preview = createIframePreviewAdapter(iframe, (op) => {
|
||||
comp.dispatch(op);
|
||||
});
|
||||
|
||||
const comp = await openComposition(compositionHtml, { preview });
|
||||
```
|
||||
|
||||
The callback references `comp` before it is declared — that is intentional and safe: the arrow function captures `comp` by closure and is only ever invoked later (by `commitPreview()` on pointer-up), by which point `comp` is assigned. This is the standard way to break the adapter ⇄ session circular dependency.
|
||||
|
||||
The `dispatch` callback is optional. Omitting it means `commitPreview()` is a no-op, which is useful if you want to handle op derivation yourself.
|
||||
|
||||
## Hit-testing: finding what the user clicked
|
||||
|
||||
`preview.elementAtPoint(x, y)` performs a synchronous hit-test at coordinates in the iframe's own coordinate space and returns the nearest `[data-hf-id]` element, or `null` for a transparent hit.
|
||||
|
||||
```typescript
|
||||
iframe.addEventListener("click", (e) => {
|
||||
// e.clientX / e.clientY are in the outer frame's space.
|
||||
// If the iframe is positioned, convert to iframe-local coords.
|
||||
const rect = iframe.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const hit = preview.elementAtPoint(x, y);
|
||||
if (hit) {
|
||||
// hit.id — the data-hf-id value
|
||||
// hit.tag — the lowercased tag name (e.g. "div", "img", "video")
|
||||
preview.select([hit.id]);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The hit-test skips elements whose computed opacity is `0` (including ancestors with `opacity: 0`), and for `<img>` elements it samples the alpha at the clicked pixel using an offscreen canvas — a transparent pixel falls through to the element behind it. Cross-origin images that taint the canvas fall back to treating the pixel as opaque.
|
||||
|
||||
The `opts.atTime` parameter is accepted but does not seek the GSAP timeline. It reflects whatever frame the composition is currently paused at in the iframe. Accurate out-of-time-band opacity queries are a future capability.
|
||||
|
||||
### Walking a click target to the nearest HF element
|
||||
|
||||
If you are working with events on the iframe's `contentDocument` directly (e.g. via a `message` bridge), use the exported `resolveNearestHfElement` function. It walks up the DOM from any node until it finds a `[data-hf-id]` ancestor, skipping the root:
|
||||
|
||||
```typescript
|
||||
import { resolveNearestHfElement } from "@hyperframes/sdk";
|
||||
|
||||
// Inside the iframe's own document context:
|
||||
iframeDoc.addEventListener("click", (e) => {
|
||||
const result = resolveNearestHfElement(
|
||||
e.target as Element | null,
|
||||
(el) => {
|
||||
// Return false to treat this element as invisible and continue the walk.
|
||||
const style = el.ownerDocument.defaultView?.getComputedStyle(el);
|
||||
return style ? parseFloat(style.opacity) !== 0 : true;
|
||||
},
|
||||
);
|
||||
if (result) {
|
||||
// result.id, result.tag
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
`resolveNearestHfElement` returns `null` when the walk exits the tree without finding a `[data-hf-id]` node, when the matching node carries `[data-hf-root]` (the root is transparent to selection), or when `isVisible` returns `false` for that node.
|
||||
|
||||
## Draft loop: 60fps drag without model mutations
|
||||
|
||||
The draft loop keeps the model clean during a drag. The SDK is **not** in the 60fps path — you call `preview.applyDraft` on every `pointermove` and `preview.commitPreview` once on `pointerup`. The model sees exactly one `moveElement` op per drag, rather than hundreds.
|
||||
|
||||
`applyDraft` sets the element's CSS `translate` directly inside the iframe — the pre-drag value composed with the accumulated delta — so the drag is visible in any composition without composition-side CSS, and works on GSAP-animated elements (a `translate` set after GSAP's first parse composes with the animated transform instead of being overwritten). Nothing in the SDK model changes. `cancelPreview` restores the pre-drag translate; `commitPreview` derives one `moveElement` op and mirrors the committed position onto the live element.
|
||||
|
||||
```typescript
|
||||
let dragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let targetId: string | null = null;
|
||||
|
||||
iframe.addEventListener("pointerdown", (e) => {
|
||||
const rect = iframe.getBoundingClientRect();
|
||||
const hit = preview.elementAtPoint(e.clientX - rect.left, e.clientY - rect.top);
|
||||
if (!hit) return;
|
||||
|
||||
dragging = true;
|
||||
targetId = hit.id;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
iframe.setPointerCapture(e.pointerId);
|
||||
});
|
||||
|
||||
iframe.addEventListener("pointermove", (e) => {
|
||||
if (!dragging || !targetId) return;
|
||||
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
|
||||
// applyDraft at 60fps — no model mutation, no patch event
|
||||
preview.applyDraft(targetId, { dx, dy });
|
||||
});
|
||||
|
||||
iframe.addEventListener("pointerup", () => {
|
||||
if (!dragging || !targetId) return;
|
||||
|
||||
// Derives a moveElement op from the accumulated dx/dy, dispatches it
|
||||
// through the callback you passed to createIframePreviewAdapter, then
|
||||
// clears the CSS vars and internal draft state.
|
||||
preview.commitPreview();
|
||||
|
||||
dragging = false;
|
||||
targetId = null;
|
||||
});
|
||||
|
||||
iframe.addEventListener("pointercancel", () => {
|
||||
// Clears the CSS vars. Model is never touched.
|
||||
preview.cancelPreview();
|
||||
dragging = false;
|
||||
targetId = null;
|
||||
});
|
||||
```
|
||||
|
||||
`DraftProps` accepts `dx`, `dy`, `width`, and `height`. Width and height are accepted by the interface but resize support (mapping to a `setStyle` op) is not yet wired — only `dx`/`dy` drive the draft CSS vars today.
|
||||
|
||||
Call `cancelPreview()` instead of `commitPreview()` to discard the drag without emitting any op. The model is never mutated and the CSS vars are cleared.
|
||||
|
||||
## Selection
|
||||
|
||||
`preview.select(ids, opts?)` sets the selection state and fires the session's `selectionchange` event on any listeners. Pass `{ additive: true }` to extend the current selection rather than replace it.
|
||||
|
||||
```typescript
|
||||
// Replace selection
|
||||
preview.select(["hf-title"]);
|
||||
|
||||
// Extend selection (e.g. shift-click)
|
||||
preview.select(["hf-logo"], { additive: true });
|
||||
|
||||
// Clear selection
|
||||
preview.select([]);
|
||||
```
|
||||
|
||||
Listen to selection changes on the session via `comp.on("selectionchange", ...)` — the adapter fires that event, not a separate event on the iframe.
|
||||
|
||||
## Pairing with embedded override mode
|
||||
|
||||
For template-driven products you typically open the composition in embedded override mode and store only the sparse delta, not the full HTML. The preview adapter works identically in that mode — pass it the same way:
|
||||
|
||||
```typescript
|
||||
const comp = await openComposition(templateHtml, {
|
||||
preview,
|
||||
overrides: existingOverrides,
|
||||
history: false,
|
||||
});
|
||||
```
|
||||
|
||||
See [Embedded Override Mode](/sdk/guides/embedded-override-mode) for the full pattern.
|
||||
|
||||
## What to build next
|
||||
|
||||
Once hit-testing and drag are working, you can use the affordance resolver to drive a context-aware inspector panel for whatever element is selected. See [Editing Affordances](/sdk/guides/editing-affordances) for how to translate a live element into capability flags and section applicability.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Adapter reference" icon="plug" href="/sdk/reference/adapters">
|
||||
Full `PreviewAdapter`, `PersistAdapter`, and related type documentation.
|
||||
</Card>
|
||||
<Card title="Editing Affordances" icon="sliders" href="/sdk/guides/editing-affordances">
|
||||
Resolve which edit controls to show for the selected element.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
title: "Editing Affordances"
|
||||
description: "Resolve capability flags and inspector sections for a selected element so your editor panel is element-aware."
|
||||
---
|
||||
|
||||
`resolveElementAffordances` answers the question "what can the user do with this element right now?" given a live DOM element and its SDK model entry. It returns two objects: `capabilities` (boolean flags for each edit action) and `sections` (which inspector panels apply). You use these to conditionally render controls in your editor's detail panel rather than showing a fixed field set for all elements.
|
||||
|
||||
<Note>
|
||||
This API lives on the `@hyperframes/sdk/editing` subpath, available since `@hyperframes/sdk@0.7.22`. If the import fails to resolve, upgrade: `npm install @hyperframes/sdk@latest`.
|
||||
</Note>
|
||||
|
||||
## Import
|
||||
|
||||
```typescript
|
||||
import { resolveElementAffordances } from "@hyperframes/sdk/editing";
|
||||
```
|
||||
|
||||
## Signature
|
||||
|
||||
```typescript
|
||||
function resolveElementAffordances(
|
||||
liveEl: HTMLElement,
|
||||
modelEl: Pick<HyperFramesElement, "text" | "animationIds" | "start"> | null,
|
||||
ctx?: AffordanceContext,
|
||||
): EditingAffordances
|
||||
```
|
||||
|
||||
- `liveEl` — a live, laid-out `HTMLElement` from the composition iframe. The resolver calls `getComputedStyle` on it, so it must be attached to a rendered document.
|
||||
- `modelEl` — the SDK model element (`comp.getElement(id)`), or `null` when the element exists in the live DOM but not in the SDK source model (generated elements). Passing `null` restricts affordances: style editing is disabled and `reasonIfDisabled` is set.
|
||||
- `ctx` — optional context for studio-specific concepts. For most custom editors, omit it or leave all flags at their defaults (`false`).
|
||||
|
||||
### `AffordanceContext`
|
||||
|
||||
```typescript
|
||||
interface AffordanceContext {
|
||||
isCompositionHost?: boolean; // default false
|
||||
isCompositionRoot?: boolean; // default false
|
||||
isInsideLockedComposition?: boolean; // default false
|
||||
isMasterView?: boolean; // default false
|
||||
}
|
||||
```
|
||||
|
||||
These flags are studio-specific. In a standalone custom editor you can omit `ctx` entirely.
|
||||
|
||||
## Return value
|
||||
|
||||
```typescript
|
||||
interface EditingAffordances {
|
||||
capabilities: DomEditCapabilities;
|
||||
sections: EditingSectionApplicability;
|
||||
}
|
||||
```
|
||||
|
||||
### `capabilities`
|
||||
|
||||
```typescript
|
||||
interface DomEditCapabilities {
|
||||
canSelect: boolean;
|
||||
canEditStyles: boolean;
|
||||
/** Directly editable authored left/top style fields. */
|
||||
canMove: boolean;
|
||||
/** Directly editable authored width/height style fields. */
|
||||
canResize: boolean;
|
||||
/** Canvas translate-drag maps here, not to canMove. */
|
||||
canApplyManualOffset: boolean;
|
||||
canApplyManualSize: boolean;
|
||||
canApplyManualRotation: boolean;
|
||||
/** Set when canEditStyles / canMove / canResize is false; explains why. */
|
||||
reasonIfDisabled?: string;
|
||||
}
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`canMove` and `canResize` indicate that the element has authored `left`/`top`/`width`/`height` style values that can be directly edited as fields. A canvas drag operation maps to `canApplyManualOffset`, not `canMove`. Do not gate drag handles on `canMove`.
|
||||
</Warning>
|
||||
|
||||
### `sections`
|
||||
|
||||
```typescript
|
||||
interface EditingSectionApplicability {
|
||||
text: boolean; // true when the element has editable text content
|
||||
media: boolean; // true for <video> and <audio>
|
||||
colorGrading: boolean; // true for <video> and <img> — element-level only
|
||||
timing: boolean; // true when data-start is present or animationCount > 0
|
||||
animation: boolean; // true when animationCount > 0
|
||||
}
|
||||
```
|
||||
|
||||
`sections.colorGrading` is element-level only: it tells you this particular element supports color grading controls. Studio shows the color grading panel for supported media elements.
|
||||
|
||||
## End-to-end example
|
||||
|
||||
```typescript
|
||||
import { openComposition, createIframePreviewAdapter } from "@hyperframes/sdk";
|
||||
import { resolveElementAffordances } from "@hyperframes/sdk/editing";
|
||||
|
||||
const iframe = document.querySelector<HTMLIFrameElement>("#composition-frame")!;
|
||||
const preview = createIframePreviewAdapter(iframe, (op) => comp.dispatch(op));
|
||||
const comp = await openComposition(compositionHtml, { preview });
|
||||
|
||||
function onElementSelected(hfId: string) {
|
||||
// 1. Get the live DOM element from the iframe.
|
||||
const liveEl = iframe.contentDocument?.querySelector<HTMLElement>(
|
||||
`[data-hf-id="${hfId}"]`,
|
||||
);
|
||||
if (!liveEl) return;
|
||||
|
||||
// 2. Get the SDK model element — null for script-generated elements.
|
||||
const modelEl = comp.getElement(hfId);
|
||||
|
||||
// 3. Resolve affordances.
|
||||
const { capabilities, sections } = resolveElementAffordances(liveEl, modelEl);
|
||||
|
||||
// 4. Render controls conditionally.
|
||||
renderInspector({ capabilities, sections, hfId });
|
||||
}
|
||||
|
||||
function renderInspector({
|
||||
capabilities,
|
||||
sections,
|
||||
hfId,
|
||||
}: {
|
||||
capabilities: ReturnType<typeof resolveElementAffordances>["capabilities"];
|
||||
sections: ReturnType<typeof resolveElementAffordances>["sections"];
|
||||
hfId: string;
|
||||
}) {
|
||||
// Show a disabled-state tooltip when editing is locked.
|
||||
if (!capabilities.canEditStyles && capabilities.reasonIfDisabled) {
|
||||
showDisabledBanner(capabilities.reasonIfDisabled);
|
||||
return;
|
||||
}
|
||||
|
||||
// Style panel — always show when editing is allowed.
|
||||
if (capabilities.canEditStyles) {
|
||||
showStylePanel(hfId);
|
||||
}
|
||||
|
||||
// Position/size fields — only for absolutely positioned elements with px values.
|
||||
if (capabilities.canMove) {
|
||||
showPositionFields(hfId);
|
||||
}
|
||||
if (capabilities.canResize) {
|
||||
showSizeFields(hfId);
|
||||
}
|
||||
|
||||
// Canvas drag handles — use canApplyManualOffset, NOT canMove.
|
||||
if (capabilities.canApplyManualOffset) {
|
||||
showDragHandles(hfId);
|
||||
}
|
||||
|
||||
// Section panels.
|
||||
if (sections.text) showTextPanel(hfId);
|
||||
if (sections.media) showMediaPanel(hfId);
|
||||
if (sections.colorGrading && myFeatureFlags.colorGrading) showColorGradingPanel(hfId);
|
||||
if (sections.timing) showTimingPanel(hfId);
|
||||
if (sections.animation) showAnimationPanel(hfId);
|
||||
}
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
**Browser-only.** `resolveElementAffordances` calls `getComputedStyle` internally. It must not be imported or called in Node, a server action, or any non-browser path. The SDK adapter (`@hyperframes/sdk/editing`) is separate from the pure core resolver precisely to keep the Node SDK path free of browser globals.
|
||||
|
||||
**Needs a laid-out DOM.** `canMove` and `canResize` require a live computed `position`, `left`, and `top`. If you call `resolveElementAffordances` before the iframe finishes layout (e.g. synchronously after `srcdoc` assignment), both flags will be `false`. Wait for the iframe's `load` event or the composition's first rendered frame before resolving affordances.
|
||||
|
||||
**`null` model means select-only.** When `modelEl` is `null` (the element is generated at runtime and has no source entry), the resolver sets `existsInSource: false`. This gives `canSelect: true` but `canEditStyles: false`, `canMove: false`, and all manual-geometry flags `false`, with `reasonIfDisabled` set to a human-readable explanation. You can still show the selection highlight; just hide or disable all edit controls.
|
||||
|
||||
**`canApplyManualOffset` vs `canMove`.** `canMove` is true only when the element has absolute/fixed positioning AND authored `left`/`top` px values AND no transform-driven geometry. `canApplyManualOffset` is true for all non-root non-host elements regardless of positioning. A translate-based canvas drag (the `applyDraft` / `commitPreview` flow in [Canvas Integration](/sdk/guides/canvas-integration)) uses `canApplyManualOffset` as its gate — it does not require `canMove`.
|
||||
|
||||
**`colorGrading` is element-level, not feature-level.** `sections.colorGrading` tells you the element type supports grading (`<video>` or `<img>`). Your own feature flag is a separate AND condition. Never replace your feature flag with this field.
|
||||
|
||||
## Type reference
|
||||
|
||||
The full type definitions — `EditingAffordances`, `DomEditCapabilities`, `EditingSectionApplicability`, `AffordanceContext`, `HyperFramesElement` — are documented at [`/sdk/reference/types`](/sdk/reference/types).
|
||||
|
||||
The pure DOM-free resolver (`resolveEditingAffordances`, `EditableElementFacts`) lives in `@hyperframes/core` and is documented at [`/packages/core`](/packages/core).
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Canvas Integration" icon="frame" href="/sdk/guides/canvas-integration">
|
||||
Get the live element and selection via hit-testing and the iframe adapter.
|
||||
</Card>
|
||||
<Card title="Types reference" icon="brackets-curly" href="/sdk/reference/types">
|
||||
Complete type definitions for the SDK public surface.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
title: "Embedded Override Mode"
|
||||
description: "Layer a sparse delta on top of a reusable base composition so the host stores only what changed."
|
||||
---
|
||||
|
||||
Embedded override mode is the pattern for template-driven products: you maintain one base composition HTML file and store only the per-instance _delta_ (the `OverrideSet`) alongside it. When a user edits their instance, the SDK accumulates further changes into that same delta — the base stays untouched.
|
||||
|
||||
This is exactly how a canvas embedder (for example, AI Studio) persists in-composition edits as a stable, reopenable delta.
|
||||
|
||||
## Opening with overrides
|
||||
|
||||
Pass the stored delta as `overrides` when you call `openComposition`. The SDK replays the override set onto the base template in one pass, so the session exposes the user's exact edited state immediately:
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
|
||||
// Stored delta — loaded from your database or storage layer.
|
||||
const storedOverrides = {
|
||||
"hf-title.text": "Acme Corp Launch",
|
||||
"hf-logo.attr.src": "/customers/acme/logo.png",
|
||||
"hf-subtitle.style.color": "#0EA5E9",
|
||||
};
|
||||
|
||||
const comp = await openComposition(templateHtml, {
|
||||
overrides: storedOverrides,
|
||||
history: false, // host owns undo — see below
|
||||
});
|
||||
```
|
||||
|
||||
After `openComposition` returns, `comp.getElements()` reflects the overridden state. Any further edits accumulate into the same delta automatically.
|
||||
|
||||
## Reading the current delta
|
||||
|
||||
Call `getOverrides()` to retrieve the current delta for storage. It returns a shallow copy of the internal override set — safe to serialize and stash:
|
||||
|
||||
```typescript
|
||||
comp.setText("hf-cta", "Get started free");
|
||||
comp.setStyle("hf-cta", { backgroundColor: "#22C55E" });
|
||||
|
||||
const nextOverrides = comp.getOverrides();
|
||||
// { "hf-title.text": "Acme Corp Launch", ..., "hf-cta.text": "Get started free", ... }
|
||||
|
||||
await db.saveOverrides(sessionId, nextOverrides);
|
||||
```
|
||||
|
||||
On the next session open, pass `nextOverrides` as `overrides` again — the user sees their exact state.
|
||||
|
||||
## Override set key format
|
||||
|
||||
Keys follow the pattern `hfId.prop.path`. The common forms are:
|
||||
|
||||
| Key | Meaning |
|
||||
|-----|---------|
|
||||
| `"hf-title.text"` | Inner text of element `hf-title` |
|
||||
| `"hf-logo.attr.src"` | `src` attribute on `hf-logo` |
|
||||
| `"hf-x.style.fontSize"` | Inline `fontSize` style on `hf-x` |
|
||||
| `"hf-card"` with value `null` | Removal marker — element was deleted |
|
||||
|
||||
A `null` value is a removal marker. When the SDK serializes the composition it omits that element entirely. This lets the host distinguish "never touched" (key absent) from "user deleted" (key present, value `null`).
|
||||
|
||||
Sub-composition elements use scoped IDs — `"hf-host/hf-leaf.text"` — where the host and leaf are separated by `/`.
|
||||
|
||||
### Font and image variable overrides
|
||||
|
||||
Variable overrides (from `setVariableValue`) live under the `var.{id}` key. Font and image values are objects, not strings:
|
||||
|
||||
```typescript
|
||||
import type { FontValue, ImageValue } from "@hyperframes/sdk";
|
||||
|
||||
// Font variable — name is the CSS font-family, source is the stylesheet URL.
|
||||
const fontOverride: FontValue = {
|
||||
name: "Inter",
|
||||
source: "https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap",
|
||||
};
|
||||
|
||||
// Image variable — url is the src; alt and fit are optional.
|
||||
const imageOverride: ImageValue = {
|
||||
url: "/customers/acme/hero.jpg",
|
||||
alt: "Acme hero image",
|
||||
fit: "cover",
|
||||
};
|
||||
|
||||
comp.setVariableValue("brand-font", fontOverride);
|
||||
comp.setVariableValue("hero-image", imageOverride);
|
||||
|
||||
const overrides = comp.getOverrides();
|
||||
// {
|
||||
// "var.brand-font": { name: "Inter", source: "https://…" },
|
||||
// "var.hero-image": { url: "/customers/acme/hero.jpg", alt: "…", fit: "cover" },
|
||||
// }
|
||||
```
|
||||
|
||||
When you read an `OverrideSet` value at a `var.*` key, narrow before treating it as a scalar — the type is `string | number | boolean | Record<string, unknown> | null`.
|
||||
|
||||
## Disabling SDK undo
|
||||
|
||||
In embedded mode the host typically owns the undo stack. The SDK already leaves history **off by default** in embedded mode (any session opened with `overrides`), so you usually don't need to do anything. Pass `history: false` explicitly only to make that intent obvious in standalone code paths, or as a guard if a call site may or may not supply `overrides`:
|
||||
|
||||
```typescript
|
||||
const comp = await openComposition(templateHtml, {
|
||||
overrides: storedOverrides,
|
||||
history: false,
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
`history: false` only disables the SDK's internal undo stack. Persistence (the `persist` adapter, if you supply one) is independent — omitting it does not affect autosave. In embedded mode, however, the host typically owns both undo and persistence, so you usually pass neither.
|
||||
</Note>
|
||||
|
||||
## Host-owned undo via `applyPatches()`
|
||||
|
||||
When the host pops an undo entry and needs to replay the inverse patches back into the SDK, use `applyPatches()`. The SDK applies the patches to the live document, updates the internal override set, and emits a `patch` event tagged with `ORIGIN_APPLY_PATCHES`.
|
||||
|
||||
```typescript
|
||||
import { ORIGIN_APPLY_PATCHES } from "@hyperframes/sdk";
|
||||
|
||||
// Host undo stack entry — inversePatches came from a prior 'patch' event.
|
||||
function hostUndo(inversePatches: JsonPatchOp[]) {
|
||||
comp.applyPatches(inversePatches);
|
||||
}
|
||||
|
||||
// Guard against undo loops — skip ORIGIN_APPLY_PATCHES events in the patch listener.
|
||||
comp.on("patch", ({ patches, inversePatches, origin }) => {
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return; // already from host undo — do not re-push
|
||||
hostHistory.push({ patches, inversePatches });
|
||||
});
|
||||
```
|
||||
|
||||
`applyPatches()` accepts an optional `opts.origin` override if you want to tag the event with a different sentinel, but the default `ORIGIN_APPLY_PATCHES` is the expected value for host undo/redo flows.
|
||||
|
||||
See [Undo, Redo, and Patches](/sdk/guides/undo-redo-and-patches) for the full patch event contract and inverse patch handling.
|
||||
|
||||
## Full example
|
||||
|
||||
```typescript
|
||||
import { openComposition, ORIGIN_APPLY_PATCHES } from "@hyperframes/sdk";
|
||||
import type { JsonPatchOp, OverrideSet } from "@hyperframes/sdk";
|
||||
|
||||
async function openUserSession(
|
||||
templateHtml: string,
|
||||
storedOverrides: OverrideSet,
|
||||
) {
|
||||
const hostHistory: Array<{ patches: readonly JsonPatchOp[]; inversePatches: readonly JsonPatchOp[] }> = [];
|
||||
let historyIndex = hostHistory.length;
|
||||
|
||||
const comp = await openComposition(templateHtml, {
|
||||
overrides: storedOverrides,
|
||||
history: false,
|
||||
});
|
||||
|
||||
// Record every user edit in the host undo stack.
|
||||
comp.on("patch", ({ patches, inversePatches, origin }) => {
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return;
|
||||
// Discard any redo entries ahead of the cursor.
|
||||
hostHistory.splice(historyIndex);
|
||||
hostHistory.push({ patches, inversePatches });
|
||||
historyIndex = hostHistory.length;
|
||||
});
|
||||
|
||||
return {
|
||||
comp,
|
||||
|
||||
undo() {
|
||||
if (historyIndex === 0) return;
|
||||
const { inversePatches } = hostHistory[--historyIndex];
|
||||
comp.applyPatches([...inversePatches]);
|
||||
},
|
||||
|
||||
redo() {
|
||||
if (historyIndex >= hostHistory.length) return;
|
||||
const { patches } = hostHistory[historyIndex++];
|
||||
comp.applyPatches([...patches]);
|
||||
},
|
||||
|
||||
async save() {
|
||||
// Only store the delta — not the full HTML.
|
||||
return comp.getOverrides();
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="openComposition reference" icon="code" href="/sdk/reference/open-composition">
|
||||
Full `OpenCompositionOptions` including `overrides`, `history`, and `persist`.
|
||||
</Card>
|
||||
<Card title="Types reference" icon="shapes" href="/sdk/reference/types">
|
||||
`OverrideSet`, `FontValue`, `ImageValue`, `PatchEvent`, and `ORIGIN_APPLY_PATCHES`.
|
||||
</Card>
|
||||
<Card title="Undo, Redo, and Patches" icon="clock-rotate-left" href="/sdk/guides/undo-redo-and-patches">
|
||||
Patch event format, inverse patch contract, and undo loop prevention.
|
||||
</Card>
|
||||
<Card title="Canvas integration" icon="browsers" href="/sdk/guides/canvas-integration">
|
||||
Wiring the SDK to a live preview iframe inside an editor canvas.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
---
|
||||
title: "Persistence"
|
||||
description: "Autosave composition edits through pluggable adapters — filesystem, memory, or your own storage backend."
|
||||
---
|
||||
|
||||
When you pass a `persist` adapter to `openComposition`, the SDK subscribes to every `change` event and schedules an async write automatically. You never call a save method after each edit — the adapter handles it.
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
|
||||
|
||||
const adapter = createFsAdapter({ root: "./project" });
|
||||
|
||||
const comp = await openComposition(html, {
|
||||
persist: adapter,
|
||||
persistPath: "composition.html", // default; can be omitted
|
||||
});
|
||||
|
||||
comp.setText("hf-title", "Draft title");
|
||||
// Autosaved on the next tick — no explicit save call needed.
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
<ParamField path="persist" type="PersistAdapter">
|
||||
The storage adapter. The SDK calls `adapter.write(persistPath, html)` after every change. Omit to skip persistence (headless / agent use).
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="persistPath" type="string">
|
||||
The path key passed to the adapter on every write. Default: `"composition.html"`. Immutable for the session lifetime — changing the path mid-session is not supported.
|
||||
</ParamField>
|
||||
|
||||
## Flushing pending writes
|
||||
|
||||
The persist queue coalesces rapid edits, so the last write may be deferred when your application closes. Call `flush()` to drain any pending write before exit:
|
||||
|
||||
```typescript
|
||||
// Ensure the latest state is committed before the process exits.
|
||||
await comp.flush();
|
||||
comp.dispose();
|
||||
```
|
||||
|
||||
`flush()` resolves when the in-flight write completes. If there is nothing pending it resolves immediately. It is a no-op when no `persist` adapter was provided.
|
||||
|
||||
## Handling write failures
|
||||
|
||||
Write errors are emitted as events rather than thrown exceptions — a failed autosave must not crash an interactive session. Subscribe with `comp.on('persist:error', …)` to handle them:
|
||||
|
||||
```typescript
|
||||
comp.on("persist:error", ({ error }) => {
|
||||
console.error("Autosave failed:", error.message, error.cause);
|
||||
showToast("Could not save — retrying next change.");
|
||||
});
|
||||
```
|
||||
|
||||
The event carries `{ error: { message: string; hint?: string; cause?: unknown } }`. The SDK retries on the next `change` event, so transient errors recover automatically.
|
||||
|
||||
## Shipped adapters
|
||||
|
||||
### `createMemoryAdapter()` — tests and demos
|
||||
|
||||
An in-process store — no file I/O, no Node.js required. Use it in unit tests, demos, or browser sessions where you want version history without disk access.
|
||||
|
||||
```typescript
|
||||
import { createMemoryAdapter } from "@hyperframes/sdk";
|
||||
|
||||
const adapter = createMemoryAdapter();
|
||||
|
||||
const comp = await openComposition(html, {
|
||||
persist: adapter,
|
||||
});
|
||||
|
||||
comp.setText("hf-title", "v1");
|
||||
await comp.flush();
|
||||
comp.setText("hf-title", "v2");
|
||||
await comp.flush();
|
||||
|
||||
const versions = await adapter.listVersions("composition.html");
|
||||
// versions[0] is newest — key "v2", versions[1] is "v1"
|
||||
|
||||
const v1Html = await adapter.loadFrom("composition.html", versions[1].key);
|
||||
```
|
||||
|
||||
`createMemoryAdapter()` also exposes `injectFault(message)` — a test helper that makes the next write fire `persist:error` instead of committing. This lets you exercise your error-handling path without real I/O failures.
|
||||
|
||||
### `createFsAdapter(opts)` — Node.js local dev
|
||||
|
||||
Writes to the filesystem and keeps a rolling version history under `.hf-versions/`. Node.js only — do not use in browser builds.
|
||||
|
||||
<Note>
|
||||
`createFsAdapter` is imported from the `@hyperframes/sdk/adapters/fs` **subpath**, not the main barrel like `createMemoryAdapter`. The fs adapter pulls in Node's `fs` module, so keeping it on a separate subpath lets browser bundles tree-shake it away — importing it from the root would drag `node:fs` into a browser build.
|
||||
</Note>
|
||||
|
||||
```typescript
|
||||
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
|
||||
|
||||
const adapter = createFsAdapter({
|
||||
root: "./project", // required — directory for composition files
|
||||
maxVersions: 20, // optional — versions to retain per file (default: 20)
|
||||
});
|
||||
```
|
||||
|
||||
<ParamField path="root" type="string">
|
||||
Root directory. The adapter writes `{root}/{persistPath}` and keeps version files under `{root}/.hf-versions/{persistPath}/`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="maxVersions" type="number">
|
||||
Maximum version snapshots to retain per file. Oldest are pruned automatically. Default: `20`.
|
||||
</ParamField>
|
||||
|
||||
**Listing and restoring versions:**
|
||||
|
||||
```typescript
|
||||
const versions = await adapter.listVersions("composition.html");
|
||||
// [ { key: "1751234567890-0003", timestamp: 1751234567890 }, ... ]
|
||||
// Entries are newest-first.
|
||||
|
||||
const olderHtml = await adapter.loadFrom("composition.html", versions[2].key);
|
||||
if (olderHtml) {
|
||||
// Re-open the old snapshot.
|
||||
const restored = await openComposition(olderHtml, { persist: adapter });
|
||||
}
|
||||
```
|
||||
|
||||
Version keys are `{timestamp}-{counter}` strings. `listVersions` returns entries newest-first; `timestamp` is the Unix milliseconds at the time of the write.
|
||||
|
||||
## Implementing a custom adapter
|
||||
|
||||
Any object that satisfies the `PersistAdapter` interface works as a drop-in. The interface is:
|
||||
|
||||
```typescript
|
||||
export interface PersistAdapter {
|
||||
read(path: string): Promise<string | undefined>;
|
||||
write(path: string, content: string): Promise<void>;
|
||||
flush(): Promise<void>;
|
||||
listVersions(path: string): Promise<PersistVersionEntry[]>;
|
||||
loadFrom(path: string, versionKey: string): Promise<string | undefined>;
|
||||
on(event: "persist:error", handler: (event: PersistErrorEvent) => void): () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Contract:**
|
||||
|
||||
- `read()` returns `undefined` for a path that has never been written.
|
||||
- `write()` is idempotent — a second write to the same path overwrites.
|
||||
- `flush()` resolves when any queued writes are committed. Return `Promise.resolve()` if writes are synchronous.
|
||||
- `listVersions()` returns entries newest-first. Entries without stored `content` are valid — `loadFrom` is called lazily by the consumer.
|
||||
- `loadFrom()` returns `undefined` when the version key is not found.
|
||||
- `on('persist:error', handler)` registers an error listener. Errors **must not** propagate as thrown exceptions — surface them through the event instead. Returns an unsubscribe function.
|
||||
|
||||
**Minimal example — an HTTP / S3-style adapter:**
|
||||
|
||||
```typescript
|
||||
import type { PersistAdapter, PersistVersionEntry, PersistErrorEvent } from "@hyperframes/sdk";
|
||||
|
||||
export function createHttpAdapter(baseUrl: string): PersistAdapter {
|
||||
const errorHandlers: Array<(e: PersistErrorEvent) => void> = [];
|
||||
|
||||
return {
|
||||
async read(path) {
|
||||
const res = await fetch(`${baseUrl}/${path}`);
|
||||
if (res.status === 404) return undefined;
|
||||
return res.text();
|
||||
},
|
||||
|
||||
async write(path, content) {
|
||||
try {
|
||||
await fetch(`${baseUrl}/${path}`, {
|
||||
method: "PUT",
|
||||
body: content,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
errorHandlers.forEach((h) => h({ error: { message, cause: err } }));
|
||||
}
|
||||
},
|
||||
|
||||
async flush() {
|
||||
// No queuing — writes are immediate.
|
||||
},
|
||||
|
||||
async listVersions(path): Promise<PersistVersionEntry[]> {
|
||||
const res = await fetch(`${baseUrl}/${path}/versions`);
|
||||
if (!res.ok) return [];
|
||||
return res.json() as Promise<PersistVersionEntry[]>;
|
||||
},
|
||||
|
||||
async loadFrom(path, versionKey) {
|
||||
const res = await fetch(`${baseUrl}/${path}/versions/${versionKey}`);
|
||||
if (!res.ok) return undefined;
|
||||
return res.text();
|
||||
},
|
||||
|
||||
on(event, handler) {
|
||||
if (event !== "persist:error") return () => {};
|
||||
errorHandlers.push(handler);
|
||||
return () => {
|
||||
const i = errorHandlers.indexOf(handler);
|
||||
if (i !== -1) errorHandlers.splice(i, 1);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
`write()` must never throw — catch errors and emit them through `on('persist:error', …)` instead. The SDK trusts that writes are fire-and-forget from its side; thrown exceptions break the internal persist queue.
|
||||
</Note>
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Adapter reference" icon="plug" href="/sdk/reference/adapters">
|
||||
Full `PersistAdapter`, `PersistVersionEntry`, and `PreviewAdapter` type definitions.
|
||||
</Card>
|
||||
<Card title="openComposition reference" icon="code" href="/sdk/reference/open-composition">
|
||||
All `OpenCompositionOptions` fields including `persist`, `persistPath`, `preview`, and `overrides`.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
title: "Querying & Editing Elements"
|
||||
description: "Find elements by property, make typed mutations, group edits with batch, and work with element handles and selection."
|
||||
---
|
||||
|
||||
The SDK exposes two layers for working with composition elements: a **query API** for reading the current document state, and a **mutation API** of typed methods (plus `dispatch()`) for making changes. Every query and every edit operates on stable `hf-id` strings — there is no cursor, no "current selection required" invariant, and no DOM reference that can go stale.
|
||||
|
||||
## Query API
|
||||
|
||||
### `getElements()`
|
||||
|
||||
Returns a flat array of `ElementSnapshot` objects representing every element in the composition — including elements nested inside sub-compositions — in document order.
|
||||
|
||||
```typescript
|
||||
const elements = comp.getElements();
|
||||
|
||||
// Filter in userland
|
||||
const images = elements.filter((el) => el.tag === "img");
|
||||
const timed = elements.filter((el) => el.start !== null);
|
||||
```
|
||||
|
||||
Each `ElementSnapshot` (aliased from `HyperFramesElement`) carries:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `id` | `string` | Leaf `hf-id` — unique within its sub-composition scope |
|
||||
| `scopedId` | `string` | Canonical dispatch target (see below) |
|
||||
| `tag` | `string` | Lowercase HTML tag name |
|
||||
| `text` | `string \| null` | Direct text content of the element |
|
||||
| `inlineStyles` | `Record<string, string>` | camelCase property names |
|
||||
| `attributes` | `Record<string, string>` | All attributes except `style`, `class`, and `data-hf-*` |
|
||||
| `classNames` | `string[]` | |
|
||||
| `start` | `number \| null` | Seconds — null when `data-start` is absent |
|
||||
| `duration` | `number \| null` | Seconds — null when neither `data-duration` nor `data-end` is present |
|
||||
| `trackIndex` | `number \| null` | |
|
||||
| `animationIds` | `string[]` | GSAP tween IDs targeting this element |
|
||||
|
||||
### `getElement(id)`
|
||||
|
||||
Fetches a single element snapshot by `id` or `scopedId`. Returns `null` when no element matches.
|
||||
|
||||
```typescript
|
||||
const el = comp.getElement("hf-title");
|
||||
if (el) {
|
||||
console.log(el.text, el.inlineStyles.color);
|
||||
}
|
||||
```
|
||||
|
||||
Both bare ids (top-level elements) and scoped ids (sub-composition elements) are accepted. A bare id resolves only against top-level elements: a leaf that exists **only** inside a sub-composition returns `null` even if its id is unique in the document — use `find()` to discover its scoped form (`"hf-HOST/hf-LEAF"`), then pass that to `getElement`.
|
||||
|
||||
### `find(query)`
|
||||
|
||||
Returns an array of `scopedId` strings for every element that matches all supplied `FindQuery` fields. All fields are optional; an empty query matches everything (equivalent to `getElements().map(el => el.scopedId)`).
|
||||
|
||||
```typescript
|
||||
import type { FindQuery } from "@hyperframes/sdk";
|
||||
|
||||
// By tag
|
||||
const imgIds = comp.find({ tag: "img" });
|
||||
|
||||
// By text content (substring match)
|
||||
const headlineIds = comp.find({ text: "Launch" });
|
||||
|
||||
// By data-name attribute
|
||||
const logoIds = comp.find({ name: "brand-logo" });
|
||||
|
||||
// By track index
|
||||
const track1Ids = comp.find({ track: 1 });
|
||||
|
||||
// Filter to elements inside a specific sub-composition (by host hf-id)
|
||||
const innerIds = comp.find({ composition: "hf-intro-scene" });
|
||||
|
||||
// Combine fields — all must match
|
||||
const [targetId] = comp.find({ tag: "h1", track: 0 });
|
||||
```
|
||||
|
||||
`FindQuery` fields:
|
||||
|
||||
| Field | Type | Matches |
|
||||
|-------|------|---------|
|
||||
| `tag` | `string` | Exact tag name |
|
||||
| `text` | `string` | Substring of `el.text` |
|
||||
| `name` | `string` | Exact value of the `data-name` attribute |
|
||||
| `track` | `number` | Exact `trackIndex` |
|
||||
| `composition` | `string` | Elements whose `scopedId` starts with `"<host-id>/"` |
|
||||
|
||||
### `scopedId` for sub-composition elements
|
||||
|
||||
When a composition embeds another composition as a sub-clip, the inner elements are addressable with a scoped id of the form `"hf-HOST/hf-LEAF"` (arbitrary depth: `"hf-A/hf-B/hf-C"`). Always use the `scopedId` as the dispatch target for sub-composition elements — passing a bare leaf id to `setText` or `setStyle` will not resolve correctly when the same leaf id appears in multiple nested scopes.
|
||||
|
||||
```typescript
|
||||
// Wrong — bare id for a sub-composition element
|
||||
comp.setText("hf-inner-title", "New text"); // may silently no-op
|
||||
|
||||
// Correct — use the scopedId returned by find() or getElements()
|
||||
const [id] = comp.find({ name: "inner-title" }); // returns "hf-scene/hf-inner-title"
|
||||
if (id) comp.setText(id, "New text");
|
||||
```
|
||||
|
||||
## Editing with typed methods
|
||||
|
||||
Typed methods are the primary editing surface. Each one dispatches a single `EditOp` and returns immediately. The change is visible in the next `getElements()` call.
|
||||
|
||||
### `setText(id, value)`
|
||||
|
||||
Sets the direct text content of an element.
|
||||
|
||||
```typescript
|
||||
comp.setText("hf-title", "Shipped.");
|
||||
comp.setText("hf-subtitle", "Available now in all regions.");
|
||||
```
|
||||
|
||||
### `setStyle(id, styles)`
|
||||
|
||||
Merges inline styles. Property names are camelCase. Pass `null` for a property to remove it.
|
||||
|
||||
```typescript
|
||||
comp.setStyle("hf-card", {
|
||||
backgroundColor: "#1A1A2E",
|
||||
borderRadius: "16px",
|
||||
opacity: "0.9",
|
||||
});
|
||||
|
||||
// Remove a previously-set inline style
|
||||
comp.setStyle("hf-card", { opacity: null });
|
||||
```
|
||||
|
||||
### `setAttribute(id, name, value)`
|
||||
|
||||
Sets an HTML attribute. Pass `null` to remove it.
|
||||
|
||||
```typescript
|
||||
comp.setAttribute("hf-hero-img", "src", "/assets/hero-v2.jpg");
|
||||
comp.setAttribute("hf-hero-img", "alt", "Product screenshot");
|
||||
|
||||
// Remove an attribute
|
||||
comp.setAttribute("hf-hero-img", "loading", null);
|
||||
```
|
||||
|
||||
### `removeElement(id)`
|
||||
|
||||
Removes an element and all its children from the composition.
|
||||
|
||||
```typescript
|
||||
comp.removeElement("hf-old-badge");
|
||||
```
|
||||
|
||||
### `addElement(parent, index, html)`
|
||||
|
||||
Inserts an HTML fragment as a child of `parent` at zero-based sibling position `index`. Pass `null` for `parent` to insert at the document body root. Returns the minted `hf-id` of the inserted root element.
|
||||
|
||||
```typescript
|
||||
const newId = comp.addElement("hf-card", 0, `<span class="badge">New</span>`);
|
||||
// newId is a fresh stable hf-id, e.g. "hf-a3k7"
|
||||
|
||||
// Append at the end (index >= child count)
|
||||
comp.addElement("hf-card", 999, `<div class="footer-note">v2.0</div>`);
|
||||
```
|
||||
|
||||
The inserted HTML must be a single-root fragment and must not contain `<script>` tags.
|
||||
|
||||
### `setVariableValue(id, value)`
|
||||
|
||||
Sets a composition variable by its variable id.
|
||||
|
||||
```typescript
|
||||
// String variable
|
||||
comp.setVariableValue("tagline", "The fast path to production.");
|
||||
|
||||
// Font variable
|
||||
comp.setVariableValue("headingFont", {
|
||||
name: "Inter",
|
||||
source: "https://fonts.googleapis.com/css2?family=Inter:wght@400;700",
|
||||
});
|
||||
|
||||
// Image variable
|
||||
comp.setVariableValue("heroBg", {
|
||||
url: "/assets/hero.jpg",
|
||||
fit: "cover",
|
||||
});
|
||||
```
|
||||
|
||||
## `batch()` — group edits into one step
|
||||
|
||||
Wrap related mutations in `batch()` to coalesce them into a single undo entry, a single persist write, and a single `change` event. This is the right tool any time two or more edits are logically inseparable.
|
||||
|
||||
```typescript
|
||||
comp.batch(() => {
|
||||
comp.setText("hf-title", "Summer Drop");
|
||||
comp.setStyle("hf-title", { color: "#FF6B35", fontSize: "112px" });
|
||||
comp.setTiming("hf-title", { start: 0, duration: 3.5 });
|
||||
comp.setAttribute("hf-logo", "src", "/assets/logo-summer.svg");
|
||||
});
|
||||
// One undo entry. One disk write. One change event.
|
||||
```
|
||||
|
||||
Batches are transactional: if the callback throws, all DOM mutations from that batch are rolled back and the composition is restored to its pre-batch state.
|
||||
|
||||
Batches can nest — only the outermost boundary emits events and triggers a persist write.
|
||||
|
||||
## Ergonomic handles
|
||||
|
||||
### `comp.element(id)` → `ElementHandle`
|
||||
|
||||
Returns a curried handle that holds the `id` string and exposes the same mutation methods as the top-level `comp.*` methods. Useful when you are making several edits to the same element.
|
||||
|
||||
```typescript
|
||||
const title = comp.element("hf-title");
|
||||
|
||||
title.setText("Shipped.");
|
||||
title.setStyle({ color: "#FFD60A", letterSpacing: "-0.02em" });
|
||||
title.setTiming({ start: 0.5, duration: 3 });
|
||||
```
|
||||
|
||||
The handle holds only the `id` string — there is no stale DOM reference hazard. Calling methods on it after `dispose()` will silently no-op via the underlying dispatch path.
|
||||
|
||||
### `comp.selection()` → `SelectionProxy`
|
||||
|
||||
Returns a proxy that resolves the *current* selection at call time and applies mutations to every selected id in one batch.
|
||||
|
||||
```typescript
|
||||
comp.setSelection(["hf-title", "hf-subtitle"]);
|
||||
|
||||
const sel = comp.selection();
|
||||
console.log(sel.ids); // ["hf-title", "hf-subtitle"]
|
||||
|
||||
// Applies setStyle to both ids as a single batch
|
||||
sel.setStyle({ opacity: "0.5" });
|
||||
```
|
||||
|
||||
The selection is a runtime concept — it does not persist and is not included in patches or the override set. Use `getSelection()` to read the current selection, and `setSelection(ids)` to change it programmatically. Pass an empty array to clear.
|
||||
|
||||
```typescript
|
||||
const current = comp.getSelection();
|
||||
comp.setSelection(["hf-logo"]);
|
||||
comp.setSelection([]); // clear
|
||||
```
|
||||
|
||||
<Note>
|
||||
`SelectionProxy` and `ElementHandle` expose the same five methods: `setStyle`, `setText`, `setAttribute`, `setTiming`, and `removeElement`. They are intentionally symmetric — switch between them freely without rethinking your call site.
|
||||
</Note>
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Composition reference" icon="cube" href="/sdk/reference/composition">
|
||||
Full method signatures for every Composition method.
|
||||
</Card>
|
||||
<Card title="Types reference" icon="brackets-curly" href="/sdk/reference/types">
|
||||
HyperFramesElement, FindQuery, ElementHandle, SelectionProxy, and more.
|
||||
</Card>
|
||||
<Card title="Edit Operations reference" icon="pen-to-square" href="/sdk/reference/edit-operations">
|
||||
Every EditOp variant — for the dispatch() and can() layers.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
---
|
||||
title: "Timing & Animation"
|
||||
description: "Set clip timing, elastic holds, GSAP tweens, and keyframed animations on composition elements."
|
||||
---
|
||||
|
||||
Every clip in a HyperFrames composition has a position on the timeline (`data-start`, `data-duration`) and an optional animation attached to it. The SDK exposes typed helpers for both: the timing API controls *when* an element appears and how long it stays on screen; the animation API controls *how* it moves through that window.
|
||||
|
||||
## Clip Timing
|
||||
|
||||
### Reading timings
|
||||
|
||||
`getElementTimings()` returns a `Record<HfId, ElementTimingSnapshot>` — one entry per element that carries timing data. Values are derived from the live DOM on every call (never cached in the snapshot). Keys are `scopedId`: for top-level elements this equals the bare `id`, but for elements inside a sub-composition it is the scoped form `"hf-HOST/hf-LEAF"`.
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
|
||||
const comp = await openComposition(html);
|
||||
const timings = comp.getElementTimings();
|
||||
|
||||
for (const [id, t] of Object.entries(timings)) {
|
||||
console.log(id, t.enterAt, "→", t.exitAt, "labels:", t.labels);
|
||||
}
|
||||
```
|
||||
|
||||
Each `ElementTimingSnapshot` contains:
|
||||
|
||||
<ResponseField name="enterAt" type="number">
|
||||
Absolute timeline position (seconds) at which the element enters.
|
||||
</ResponseField>
|
||||
<ResponseField name="exitAt" type="number">
|
||||
Absolute timeline position (seconds) at which the element exits.
|
||||
</ResponseField>
|
||||
<ResponseField name="labels" type="string[]">
|
||||
GSAP timeline label names whose numeric position falls within `[enterAt, exitAt]`. Parsed fresh from the GSAP script on every `getElementTimings()` call — never stale.
|
||||
</ResponseField>
|
||||
|
||||
<Note>
|
||||
`getElementTimings()` only includes elements that have `data-start` and either `data-duration` or `data-end` attributes. Untimed elements are omitted. The method prefers `data-duration` over `data-end − data-start` when both exist, matching the behavior of `setTiming`.
|
||||
</Note>
|
||||
|
||||
### Setting timing on one element
|
||||
|
||||
```typescript
|
||||
// Move hf-title to start at 1.0 s, last 2.5 s, on track 0
|
||||
comp.setTiming("hf-title", { start: 1.0, duration: 2.5, trackIndex: 0 });
|
||||
```
|
||||
|
||||
All three fields are optional — pass only what you want to change:
|
||||
|
||||
<ParamField path="start" type="number">
|
||||
Clip start time in seconds.
|
||||
</ParamField>
|
||||
<ParamField path="duration" type="number">
|
||||
Clip duration in seconds.
|
||||
</ParamField>
|
||||
<ParamField path="trackIndex" type="number">
|
||||
Zero-based track index.
|
||||
</ParamField>
|
||||
|
||||
### Updating multiple elements in one batch
|
||||
|
||||
`setElementTiming(map)` dispatches one `setTiming` op per entry inside a single batch, so the history records one undo step and patch listeners see one event:
|
||||
|
||||
```typescript
|
||||
comp.setElementTiming({
|
||||
"hf-title": { start: 0.5, duration: 2.0 },
|
||||
"hf-logo": { start: 0.0, duration: 5.0, trackIndex: 1 },
|
||||
"hf-cta": { start: 3.0, duration: 2.5 },
|
||||
});
|
||||
```
|
||||
|
||||
Unknown ids are silently skipped.
|
||||
|
||||
### Elastic holds
|
||||
|
||||
An elastic hold freezes or loops a portion of an element's timeline window. Use it to hold a static frame between two animated segments without changing clip duration.
|
||||
|
||||
```typescript
|
||||
comp.setHold("hf-card", {
|
||||
start: 1.5, // hold begins at this time within the composition
|
||||
end: 4.0, // hold ends at this time
|
||||
fill: "freeze", // "freeze" | "loop"
|
||||
});
|
||||
```
|
||||
|
||||
`ElasticHold` fields:
|
||||
|
||||
<ParamField path="start" type="number">
|
||||
Absolute composition time at which the hold begins.
|
||||
</ParamField>
|
||||
<ParamField path="end" type="number">
|
||||
Absolute composition time at which the hold ends.
|
||||
</ParamField>
|
||||
<ParamField path="fill" type='"freeze" | "loop"'>
|
||||
`"freeze"` holds the last frame until `end`; `"loop"` repeats the segment from `start` back to `start`.
|
||||
</ParamField>
|
||||
|
||||
---
|
||||
|
||||
## GSAP Tweens
|
||||
|
||||
GSAP tweens are the primary animation primitive. The SDK reads tween IDs from `element.animationIds` (returned by the query API) and writes through `addGsapTween`, `setGsapTween`, and `removeGsapTween`.
|
||||
|
||||
### Adding a tween
|
||||
|
||||
```typescript
|
||||
const animId = comp.addGsapTween("hf-title", {
|
||||
method: "from",
|
||||
position: 0.5,
|
||||
duration: 0.6,
|
||||
ease: "power3.out",
|
||||
fromProperties: { opacity: 0, y: 40 },
|
||||
});
|
||||
// animId is the newly-assigned animation ID — store it for subsequent edits
|
||||
```
|
||||
|
||||
`addGsapTween` returns the newly-assigned animation ID as a `string`.
|
||||
|
||||
### GsapTweenSpec fields
|
||||
|
||||
<ParamField path="method" type='"from" | "to" | "fromTo" | "set"'>
|
||||
The GSAP timeline method to call.
|
||||
</ParamField>
|
||||
<ParamField path="position" type="number | string">
|
||||
Timeline position: a number (seconds) or a label-relative string (e.g. `"intro+=0.3"`). Number-only is required for `addWithKeyframes` / `replaceWithKeyframes` — see [Keyframes](#keyframes) below.
|
||||
</ParamField>
|
||||
<ParamField path="duration" type="number">
|
||||
Tween duration in seconds.
|
||||
</ParamField>
|
||||
<ParamField path="ease" type="string">
|
||||
GSAP ease string, e.g. `"power2.inOut"`.
|
||||
</ParamField>
|
||||
<ParamField path="fromProperties" type="Record<string, unknown>">
|
||||
Starting properties — used with `"from"` and `"fromTo"`.
|
||||
</ParamField>
|
||||
<ParamField path="toProperties" type="Record<string, unknown>">
|
||||
Ending properties — used with `"fromTo"`.
|
||||
</ParamField>
|
||||
<ParamField path="properties" type="Record<string, unknown>">
|
||||
Animation target properties — used with `"to"` tweens.
|
||||
</ParamField>
|
||||
<ParamField path="repeat" type="number">
|
||||
Number of repeats (`-1` = infinite). Maps to GSAP's `repeat` option.
|
||||
</ParamField>
|
||||
<ParamField path="yoyo" type="boolean">
|
||||
When `true`, alternates direction on each repeat.
|
||||
</ParamField>
|
||||
<ParamField path="stagger" type="number | Record<string, unknown>">
|
||||
GSAP stagger amount (seconds) or a full stagger config object.
|
||||
</ParamField>
|
||||
|
||||
### Modifying a tween
|
||||
|
||||
```typescript
|
||||
// Change ease and duration — all other fields stay as-is
|
||||
comp.setGsapTween(animId, { ease: "elastic.out(1, 0.5)", duration: 0.9 });
|
||||
```
|
||||
|
||||
`setGsapTween` takes the animation ID and a `Partial<GsapTweenSpec>` — only the keys you pass are updated.
|
||||
|
||||
### Removing a tween
|
||||
|
||||
```typescript
|
||||
comp.removeGsapTween(animId);
|
||||
```
|
||||
|
||||
### Looking up animation IDs
|
||||
|
||||
Query the element snapshot to find animation IDs attached to a given element:
|
||||
|
||||
```typescript
|
||||
const el = comp.getElement("hf-title");
|
||||
const [firstAnimId] = el?.animationIds ?? [];
|
||||
if (firstAnimId) {
|
||||
comp.setGsapTween(firstAnimId, { ease: "none" });
|
||||
}
|
||||
```
|
||||
|
||||
### Feature-detecting advanced ops
|
||||
|
||||
Some GSAP operations (such as `setGsapKeyframe`, arc paths, and label ops) require the parser engine, which ships in a later phase. Use `can()` to gate them before dispatching:
|
||||
|
||||
```typescript
|
||||
const check = comp.can({
|
||||
type: "setGsapTween",
|
||||
animationId: firstAnimId,
|
||||
properties: { ease: "power3.inOut" },
|
||||
});
|
||||
|
||||
if (!check.ok) {
|
||||
if (check.code === "E_NO_GSAP_TIMELINE") {
|
||||
// Parser engine not yet available — show a disabled state or skip
|
||||
console.warn("GSAP timeline not available:", check.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
comp.dispatch({ type: "setGsapTween", animationId: firstAnimId, properties: { ease: "power3.inOut" } });
|
||||
```
|
||||
|
||||
Stable error codes returned by `can()`:
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `E_TARGET_NOT_FOUND` | The target `HfId` does not exist in the document. |
|
||||
| `E_NO_ROOT` | The document has no root element. |
|
||||
| `E_NO_GSAP_TIMELINE` | Op requires the GSAP parser engine, which is not yet available. |
|
||||
| `E_NO_GSAP_SCRIPT` | The composition has no embedded GSAP script. |
|
||||
|
||||
---
|
||||
|
||||
## Keyframes
|
||||
|
||||
For keyframe-based animations, use `addWithKeyframes` and `replaceWithKeyframes`. Both operate on the GSAP CSS-keyframes layer and return the minted animation ID.
|
||||
|
||||
### Adding a keyframed tween
|
||||
|
||||
```typescript
|
||||
const animId = comp.addWithKeyframes(
|
||||
"#hf-badge", // CSS selector targeting the element
|
||||
1.0, // timeline position in seconds (number only)
|
||||
0.8, // duration in seconds
|
||||
[
|
||||
{ percentage: 0, properties: { opacity: 0, scale: 0.8 } },
|
||||
{ percentage: 60, properties: { opacity: 1, scale: 1.05 }, ease: "power2.out" },
|
||||
{ percentage: 100, properties: { scale: 1 } },
|
||||
],
|
||||
"power2.inOut", // optional overall ease
|
||||
);
|
||||
```
|
||||
|
||||
Returns the new animation ID string, or `""` if the op was rejected.
|
||||
|
||||
### Replacing an existing keyframed tween
|
||||
|
||||
```typescript
|
||||
const newAnimId = comp.replaceWithKeyframes(
|
||||
oldAnimId,
|
||||
"#hf-badge",
|
||||
1.0,
|
||||
1.2,
|
||||
[
|
||||
{ percentage: 0, properties: { x: -60, opacity: 0 } },
|
||||
{ percentage: 100, properties: { x: 0, opacity: 1 }, ease: "back.out(1.7)" },
|
||||
],
|
||||
);
|
||||
// newAnimId !== oldAnimId — position-derived IDs renumber after the remove
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`replaceWithKeyframes` is equivalent to `removeGsapTween` + `addWithKeyframes` in one atomic op. Because position-derived tween IDs renumber after the removal step, the returned ID is always a **new** ID and must not be assumed equal to the input `animationId`. Re-query `element.animationIds` after a replace to get the current set.
|
||||
</Warning>
|
||||
|
||||
### KeyframeSpec fields
|
||||
|
||||
<ParamField path="percentage" type="number">
|
||||
Position within the tween as a percentage (0–100).
|
||||
</ParamField>
|
||||
<ParamField path="properties" type="Record<string, number | string>">
|
||||
CSS / GSAP properties at this keyframe.
|
||||
</ParamField>
|
||||
<ParamField path="ease" type="string">
|
||||
Per-keyframe ease applied *from* this keyframe to the next.
|
||||
</ParamField>
|
||||
<ParamField path="auto" type="boolean">
|
||||
GSAP endpoint flag — when `true`, this keyframe picks up the element's current value automatically.
|
||||
</ParamField>
|
||||
|
||||
### Lower-level keyframe and label ops
|
||||
|
||||
For lower-level operations — individual keyframe mutations (`addGsapKeyframe`, `setGsapKeyframe`, `removeGsapKeyframe`), property removal, arc paths, label insertion, and animation splitting — use `dispatch()` directly with the corresponding `EditOp` types. See the [Edit Operations reference](/sdk/reference/edit-operations) for the full op union.
|
||||
|
||||
---
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Edit Operations" icon="code" href="/sdk/reference/edit-operations">
|
||||
Full reference for every op type in the `EditOp` union, including lower-level keyframe and arc ops.
|
||||
</Card>
|
||||
<Card title="Types" icon="brackets-curly" href="/sdk/reference/types">
|
||||
`GsapTweenSpec`, `KeyframeSpec`, `ElasticHold`, `ElementTimingSnapshot`, and all related types.
|
||||
</Card>
|
||||
<Card title="GSAP Animation Guide" icon="wand-magic-sparkles" href="/guides/gsap-animation">
|
||||
Authoring GSAP timelines in composition HTML.
|
||||
</Card>
|
||||
<Card title="Keyframes Guide" icon="film" href="/guides/keyframes">
|
||||
Keyframe authoring patterns and best practices.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
---
|
||||
title: "Undo, Redo & Patches"
|
||||
description: "Use the SDK's built-in undo stack, subscribe to patch events, and integrate with a host application's own history."
|
||||
---
|
||||
|
||||
The SDK ships undo/redo by default for standalone sessions and emits RFC 6902 JSON patches on every change. Patch events are the integration seam for host application history, collaborative editing, audit logs, and embedded override mode.
|
||||
|
||||
## Built-in Undo and Redo
|
||||
|
||||
Standalone sessions (those without an `overrides` option) automatically attach a history module. You call `undo()` and `redo()` directly on the composition, and guard UI state with `canUndo()` and `canRedo()`.
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
|
||||
const comp = await openComposition(html);
|
||||
|
||||
comp.setText("hf-title", "Draft A");
|
||||
comp.setText("hf-title", "Draft B");
|
||||
|
||||
comp.canUndo(); // true
|
||||
comp.undo(); // reverts "Draft B" → "Draft A"
|
||||
comp.redo(); // reapplies "Draft B"
|
||||
comp.canRedo(); // false — already at tip
|
||||
```
|
||||
|
||||
<Tip>
|
||||
`canUndo()` and `canRedo()` return `false` when the stack is empty and also when `history: false` was passed to `openComposition`. Check them before showing undo/redo controls.
|
||||
</Tip>
|
||||
|
||||
### Opting out of the built-in history
|
||||
|
||||
Pass `history: false` when the host application owns the undo stack and you want the SDK to emit patches without building a parallel internal stack:
|
||||
|
||||
```typescript
|
||||
const comp = await openComposition(html, {
|
||||
overrides: storedOverrides,
|
||||
history: false,
|
||||
});
|
||||
// comp.undo() / comp.redo() are no-ops — host drives history via applyPatches()
|
||||
```
|
||||
|
||||
<Note>
|
||||
`history: false` only disables the SDK undo stack. Persistence (auto-save) is independent — passing `history: false` does not suppress disk writes.
|
||||
</Note>
|
||||
|
||||
### Coalescing rapid edits
|
||||
|
||||
The history module coalesces patch events into one undo entry when two conditions are met: the same op types and same element paths are touched within a configurable window. This prevents a slider drag from flooding the stack with hundreds of individual entries.
|
||||
|
||||
The default coalesce window is 300 ms. Override it in `openComposition`:
|
||||
|
||||
```typescript
|
||||
const comp = await openComposition(html, {
|
||||
coalesceMs: 500, // ms — increase for slower interactions
|
||||
});
|
||||
```
|
||||
|
||||
For more on the coalesce contract and the `HistoryOptions` type, see [Utilities](/sdk/reference/utilities).
|
||||
|
||||
---
|
||||
|
||||
## Patch Events
|
||||
|
||||
Every committed change emits a `patch` event. Subscribe to mirror SDK edits into your host state, audit log, or collaboration channel.
|
||||
|
||||
```typescript
|
||||
const off = comp.on("patch", ({ formatVersion, patches, inversePatches, origin, opTypes }) => {
|
||||
if (formatVersion !== 1) throw new Error("Unexpected patch format version");
|
||||
saveToDB({ patches, inversePatches, origin, opTypes });
|
||||
});
|
||||
|
||||
// Unsubscribe when done
|
||||
off();
|
||||
```
|
||||
|
||||
### PatchEvent fields
|
||||
|
||||
<ResponseField name="formatVersion" type="1">
|
||||
Always `1` in the current SDK. Check this and reject unknown versions — a bump means a breaking change.
|
||||
</ResponseField>
|
||||
<ResponseField name="patches" type="JsonPatchOp[]">
|
||||
Forward patches that were applied: the add/remove/replace ops describing how the document changed.
|
||||
</ResponseField>
|
||||
<ResponseField name="inversePatches" type="JsonPatchOp[]">
|
||||
Inverse patches that undo this change. Store these alongside `patches` to support host-owned undo.
|
||||
</ResponseField>
|
||||
<ResponseField name="origin" type="unknown">
|
||||
The origin tag that was passed to `dispatch()` or `batch()`. Use it to route or filter events.
|
||||
</ResponseField>
|
||||
<ResponseField name="opTypes" type="string[]">
|
||||
Semantic names of the operations that produced this event (e.g. `["setStyle"]`). Useful for analytics and history labels. Not versioned — treat as informational.
|
||||
</ResponseField>
|
||||
|
||||
### JsonPatchOp
|
||||
|
||||
The SDK emits and accepts only the add/remove/replace subset of RFC 6902:
|
||||
|
||||
```typescript
|
||||
interface JsonPatchOp {
|
||||
op: "add" | "remove" | "replace";
|
||||
path: string; // JSON Pointer (RFC 6901), e.g. "/elements/hf-title/style/color"
|
||||
value?: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
The SDK never emits `move`, `copy`, or `test` ops, and `applyPatches()` ignores them if passed.
|
||||
|
||||
---
|
||||
|
||||
## Origin Model
|
||||
|
||||
Every mutation is tagged with an origin string. The SDK defines two built-in origins:
|
||||
|
||||
| Constant | Value | When used |
|
||||
|----------|-------|-----------|
|
||||
| `ORIGIN_LOCAL` | `"local"` | Default for all `dispatch()` and `batch()` calls |
|
||||
| `ORIGIN_APPLY_PATCHES` | `"@hyperframes/sdk:applyPatches"` | Automatically applied by `applyPatches()` |
|
||||
|
||||
Both are exported from `@hyperframes/sdk`.
|
||||
|
||||
### Setting a custom origin
|
||||
|
||||
Pass `origin` in the options to `dispatch()` or `batch()` to tag the event for downstream routing:
|
||||
|
||||
```typescript
|
||||
import { ORIGIN_LOCAL } from "@hyperframes/sdk";
|
||||
|
||||
comp.dispatch(
|
||||
{ type: "setStyle", target: "hf-title", styles: { color: "#0EA5E9" } },
|
||||
{ origin: "ui:color-picker" },
|
||||
);
|
||||
|
||||
comp.batch(() => {
|
||||
comp.setText("hf-cta", "Buy Now");
|
||||
comp.setStyle("hf-cta", { backgroundColor: "#22C55E" });
|
||||
}, { origin: "template-wizard" });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Host-Owned History with applyPatches
|
||||
|
||||
When `history: false`, the host maintains its own undo stack from the patch events the SDK emits. To replay an undo step back into the SDK, call `applyPatches()` with the inverse patches from that stack entry:
|
||||
|
||||
```typescript
|
||||
// Host undo stack entry (built from PatchEvent)
|
||||
type HostEntry = {
|
||||
patches: JsonPatchOp[];
|
||||
inversePatches: JsonPatchOp[];
|
||||
};
|
||||
|
||||
let history: HostEntry[] = [];
|
||||
|
||||
comp.on("patch", ({ patches, inversePatches, origin }) => {
|
||||
// Skip — this is the SDK replaying a host-initiated undo
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return;
|
||||
history.push({ patches, inversePatches });
|
||||
});
|
||||
|
||||
function hostUndo() {
|
||||
const entry = history.pop();
|
||||
if (!entry) return;
|
||||
comp.applyPatches(entry.inversePatches);
|
||||
}
|
||||
```
|
||||
|
||||
### Preventing undo loops
|
||||
|
||||
`applyPatches()` auto-tags its patch event with `ORIGIN_APPLY_PATCHES`. Your listener **must** skip that origin or you create an infinite loop:
|
||||
|
||||
```typescript
|
||||
import { ORIGIN_APPLY_PATCHES } from "@hyperframes/sdk";
|
||||
|
||||
comp.on("patch", ({ patches, inversePatches, origin }) => {
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return; // <-- required guard
|
||||
history.push({ patches, inversePatches });
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Omitting the `ORIGIN_APPLY_PATCHES` guard causes every `applyPatches()` call to push a new entry onto the host stack, which triggers another `applyPatches()`, and so on. Always check `origin` first.
|
||||
</Warning>
|
||||
|
||||
`ORIGIN_APPLY_PATCHES` is a namespaced string (`"@hyperframes/sdk:applyPatches"`) rather than a `Symbol` so it survives `postMessage`, structured clone, and JSON round-trips — important when the host forwards patch events across frames or workers.
|
||||
|
||||
### applyPatches with a custom origin
|
||||
|
||||
If you want downstream listeners to receive an explicit marker different from the default:
|
||||
|
||||
```typescript
|
||||
comp.applyPatches(entry.inversePatches, { origin: "host:undo" });
|
||||
```
|
||||
|
||||
Any origin other than `ORIGIN_APPLY_PATCHES` will enter the history module if `history` is still attached. Use `history: false` or a `trackedOrigins` filter when running host-owned history to avoid double-stacking.
|
||||
|
||||
---
|
||||
|
||||
## Embedded Override Mode Integration
|
||||
|
||||
In embedded (T3) mode, the host stores only the sparse override delta for each user. Pair `history: false` with `applyPatches()` to let the host drive undo while the SDK updates the composition state:
|
||||
|
||||
```typescript
|
||||
import { openComposition, ORIGIN_APPLY_PATCHES } from "@hyperframes/sdk";
|
||||
|
||||
const comp = await openComposition(baseTemplateHtml, {
|
||||
overrides: storedUserOverrides,
|
||||
history: false,
|
||||
});
|
||||
|
||||
let hostStack: HostEntry[] = [];
|
||||
|
||||
comp.on("patch", ({ patches, inversePatches, origin }) => {
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return;
|
||||
hostStack.push({ patches, inversePatches });
|
||||
// Persist the updated override set
|
||||
saveOverrides(comp.getOverrides());
|
||||
});
|
||||
|
||||
function undo() {
|
||||
const entry = hostStack.pop();
|
||||
if (entry) comp.applyPatches(entry.inversePatches);
|
||||
}
|
||||
```
|
||||
|
||||
See [Embedded Override Mode](/sdk/guides/embedded-override-mode) for the full T3 integration pattern.
|
||||
|
||||
---
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Composition API" icon="code" href="/sdk/reference/composition">
|
||||
Full method signatures for `undo`, `redo`, `canUndo`, `canRedo`, `on`, `applyPatches`, `dispatch`, and `batch`.
|
||||
</Card>
|
||||
<Card title="Types" icon="brackets-curly" href="/sdk/reference/types">
|
||||
`PatchEvent`, `JsonPatchOp`, `ORIGIN_APPLY_PATCHES`, `ORIGIN_LOCAL`, and related types.
|
||||
</Card>
|
||||
<Card title="Utilities" icon="wrench" href="/sdk/reference/utilities">
|
||||
`HistoryOptions`, `coalesceMs`, `trackedOrigins`, and `createHistory` internals.
|
||||
</Card>
|
||||
<Card title="Embedded Override Mode" icon="layer-group" href="/sdk/guides/embedded-override-mode">
|
||||
Full T3 embedded integration with host-owned history.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: "SDK Overview"
|
||||
description: "Headless, framework-neutral composition editing engine — query, mutate, patch, and persist without a browser UI."
|
||||
---
|
||||
|
||||
`@hyperframes/sdk` is the editing engine inside HyperFrames Studio and the CLI. It opens composition HTML, exposes query and mutation APIs, emits RFC 6902 JSON patches, tracks undo/redo, and persists changes through pluggable adapters — all without requiring React, Studio, or a browser UI.
|
||||
|
||||
```bash
|
||||
npm install @hyperframes/sdk
|
||||
```
|
||||
|
||||
## Mental model
|
||||
|
||||
**Every edit targets a stable `hf-id`.** The SDK stamps all elements with `data-hf-id` identifiers before any query or mutation runs. This means edits never depend on mouse state or a UI selection — agents and backend jobs operate on the same surface as a Studio user.
|
||||
|
||||
**Typed methods are sugar over `dispatch()`.** `comp.setText("hf-title", "Hello")` is exactly equivalent to `comp.dispatch({ type: "setText", target: "hf-title", value: "Hello" })`. Both go through the same validation and emit the same patch event. Use typed methods for clarity; use `dispatch()` when you're building data-driven automation or want to feed programmatic op arrays.
|
||||
|
||||
**Patches are the source of truth for history and sync.** Every committed change emits a `PatchEvent` with forward patches and inverse patches (RFC 6902 `add`/`remove`/`replace` ops). The undo stack replays inverse patches; embedded hosts replay the same patches into their own state machine; collaboration layers forward them to other clients. You can subscribe to `patch` events and mirror SDK mutations anywhere without re-parsing the HTML.
|
||||
|
||||
**Adapters decouple persistence and preview.** The SDK never reaches the filesystem or an iframe directly. You supply a `PersistAdapter` (memory, filesystem, S3, HTTP — same interface) and optionally a `PreviewAdapter`. The session fires `persist:error` events on write failures instead of throwing. This makes the SDK equally at home in a Node.js agent, a browser editor, and a CI pipeline.
|
||||
|
||||
**Standalone vs embedded mode.** In standalone mode you own the HTML and the SDK owns history and autosave. In embedded (override) mode you supply a base template and a sparse `OverrideSet` delta; the SDK folds the delta onto the template at open time, accumulates further edits into the override set, and lets you store only the delta — the base template stays untouched.
|
||||
|
||||
## Guides
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Quickstart" icon="bolt" href="/sdk/quickstart">
|
||||
Open a composition, make edits, serialize, and add persistence in five minutes.
|
||||
</Card>
|
||||
<Card title="Querying & Editing Elements" icon="magnifying-glass" href="/sdk/guides/querying-and-editing">
|
||||
getElements, find, typed methods, batch, element handles, and selection.
|
||||
</Card>
|
||||
<Card title="Timing & Animation" icon="clock" href="/sdk/guides/timing-and-animation">
|
||||
setTiming, setHold, GSAP tween and keyframe operations.
|
||||
</Card>
|
||||
<Card title="Undo, Redo & Patches" icon="arrow-rotate-left" href="/sdk/guides/undo-redo-and-patches">
|
||||
History module, patch events, applyPatches, and origin tagging.
|
||||
</Card>
|
||||
<Card title="Persistence" icon="floppy-disk" href="/sdk/guides/persistence">
|
||||
Memory, filesystem, and custom adapters. Version history and flush.
|
||||
</Card>
|
||||
<Card title="Embedded Override Mode" icon="layers" href="/sdk/guides/embedded-override-mode">
|
||||
Template-driven products with host-owned undo and delta storage.
|
||||
</Card>
|
||||
<Card title="Canvas Integration" icon="paintbrush" href="/sdk/guides/canvas-integration">
|
||||
Connecting the SDK to an iframe preview surface.
|
||||
</Card>
|
||||
<Card title="Editing Affordances" icon="sliders" href="/sdk/guides/editing-affordances">
|
||||
Resolve which controls to show for a live element.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Reference
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="openComposition" icon="door-open" href="/sdk/reference/open-composition">
|
||||
The single entry point — options, modes, and examples.
|
||||
</Card>
|
||||
<Card title="Composition" icon="cube" href="/sdk/reference/composition">
|
||||
Full method reference for the session object.
|
||||
</Card>
|
||||
<Card title="Edit Operations" icon="pen-to-square" href="/sdk/reference/edit-operations">
|
||||
Every EditOp variant with field-level documentation.
|
||||
</Card>
|
||||
<Card title="Types" icon="brackets-curly" href="/sdk/reference/types">
|
||||
HyperFramesElement, FindQuery, PatchEvent, OverrideSet, and more.
|
||||
</Card>
|
||||
<Card title="Adapters" icon="plug" href="/sdk/reference/adapters">
|
||||
PersistAdapter and PreviewAdapter interfaces and built-in implementations.
|
||||
</Card>
|
||||
<Card title="Utilities" icon="wrench" href="/sdk/reference/utilities">
|
||||
buildDocument, flatElements, UnsupportedOpError, and helper exports.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Tip>
|
||||
If you want to render a composition to MP4 or WebM rather than edit it, see the [CLI](/packages/cli) or [producer](/packages/producer). The SDK is the editing layer — rendering is a separate pipeline.
|
||||
</Tip>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: "SDK Quickstart"
|
||||
description: "Open a composition, query and edit elements, serialize, and add autosave in minutes."
|
||||
---
|
||||
|
||||
This guide walks you through the core SDK loop from scratch. You will open a composition HTML string, find and edit elements by their stable `hf-id`, serialize the result, and then extend the example to save changes to disk automatically.
|
||||
|
||||
## Open, edit, serialize
|
||||
|
||||
<Steps>
|
||||
<Step title="Install the package">
|
||||
```bash
|
||||
npm install @hyperframes/sdk
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Open a composition">
|
||||
`openComposition` parses the HTML, stamps any elements that lack `data-hf-id` attributes, and returns a `Composition` session.
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
|
||||
const html = `<div class="clip" data-start="0" data-duration="5"
|
||||
data-hf-id="hf-title">Launch day</div>`;
|
||||
|
||||
const comp = await openComposition(html);
|
||||
```
|
||||
|
||||
The call is async because it runs the ID-stamping pass over the DOM before returning.
|
||||
</Step>
|
||||
|
||||
<Step title="Find elements">
|
||||
Use `getElements()` for a flat snapshot of everything in the composition, or `find()` to filter by tag, text content, `data-name` attribute, track index, or sub-composition host.
|
||||
|
||||
```typescript
|
||||
// All elements
|
||||
const all = comp.getElements();
|
||||
|
||||
// Elements whose text contains "Launch"
|
||||
const ids = comp.find({ text: "Launch" });
|
||||
|
||||
// A specific element by its hf-id
|
||||
const el = comp.getElement("hf-title");
|
||||
console.log(el?.text); // "Launch day"
|
||||
```
|
||||
|
||||
`find()` returns an array of `scopedId` strings. For top-level elements, `scopedId === id`. For elements inside inlined sub-compositions, it is `"hf-HOST/hf-LEAF"`.
|
||||
</Step>
|
||||
|
||||
<Step title="Edit elements with typed methods">
|
||||
Typed methods are the most readable way to mutate a composition. Each one translates directly into a dispatched `EditOp` and emits a patch event.
|
||||
|
||||
```typescript
|
||||
// Change text
|
||||
comp.setText("hf-title", "Launch day — we shipped!");
|
||||
|
||||
// Change inline styles (camelCase property names)
|
||||
comp.setStyle("hf-title", {
|
||||
color: "#FFD60A",
|
||||
fontSize: "96px",
|
||||
fontWeight: "700",
|
||||
});
|
||||
|
||||
// Set or clear an attribute (null removes it)
|
||||
comp.setAttribute("hf-logo", "src", "/assets/logo-v2.png");
|
||||
|
||||
// Adjust clip timing
|
||||
comp.setTiming("hf-title", { start: 0.5, duration: 4 });
|
||||
|
||||
// Set a composition variable
|
||||
comp.setVariableValue("brandColor", "#6C5CE7");
|
||||
```
|
||||
|
||||
Use `batch()` when several mutations should collapse into one undo entry, one persist write, and one `change` event:
|
||||
|
||||
```typescript
|
||||
comp.batch(() => {
|
||||
comp.setText("hf-title", "Launch day — we shipped!");
|
||||
comp.setStyle("hf-title", { color: "#FFD60A" });
|
||||
comp.setTiming("hf-title", { start: 0.5, duration: 4 });
|
||||
});
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Serialize and dispose">
|
||||
`serialize()` returns the full updated HTML string. Call `dispose()` when you are done to release event handlers and any internal state.
|
||||
|
||||
```typescript
|
||||
const updatedHtml = comp.serialize();
|
||||
comp.dispose();
|
||||
|
||||
// updatedHtml is ready to write to disk, send to a renderer, or store in a database.
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Add a persistence adapter
|
||||
|
||||
The headless pattern above is fine for one-shot transforms. When you want the SDK to autosave after every edit, pass a `PersistAdapter`.
|
||||
|
||||
The filesystem adapter (`@hyperframes/sdk/adapters/fs`) writes to a local directory and keeps a rolling version history.
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
// Load the current HTML from disk (or use a template string on first run).
|
||||
const html = await readFile("./project/index.html", "utf8").catch(() => "<div></div>");
|
||||
|
||||
const comp = await openComposition(html, {
|
||||
persist: createFsAdapter({ root: "./project" }),
|
||||
persistPath: "index.html",
|
||||
});
|
||||
|
||||
// Persistence failures surface as events, not thrown errors.
|
||||
comp.on("persist:error", ({ error }) => {
|
||||
console.error("Autosave failed:", error.message);
|
||||
});
|
||||
|
||||
comp.setText("hf-title", "Autosaved title");
|
||||
comp.setStyle("hf-title", { color: "#22C55E" });
|
||||
|
||||
// Drain any pending write before the process exits.
|
||||
await comp.flush();
|
||||
comp.dispose();
|
||||
```
|
||||
|
||||
The adapter writes `./project/index.html` after every mutation and keeps up to 20 version snapshots under `./project/.hf-versions/`.
|
||||
|
||||
<Note>
|
||||
Disabling undo (`history: false`) does **not** disable autosave. The two are independent. Passing `history: false` is only necessary when you are managing the undo stack yourself.
|
||||
</Note>
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Querying & Editing Elements" icon="magnifying-glass" href="/sdk/guides/querying-and-editing">
|
||||
FindQuery fields, scopedId for sub-compositions, batch semantics, and element handles.
|
||||
</Card>
|
||||
<Card title="Undo, Redo & Patches" icon="arrow-rotate-left" href="/sdk/guides/undo-redo-and-patches">
|
||||
History module, patch events for host sync, and applyPatches loop prevention.
|
||||
</Card>
|
||||
<Card title="Persistence" icon="floppy-disk" href="/sdk/guides/persistence">
|
||||
Adapter selection, version history, and implementing a custom adapter.
|
||||
</Card>
|
||||
<Card title="openComposition reference" icon="door-open" href="/sdk/reference/open-composition">
|
||||
Full option reference — adapters, overrides, coalesce window, and more.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
---
|
||||
title: "Adapters"
|
||||
description: "Persistence and preview adapter interfaces, contracts, and the built-in factory functions."
|
||||
---
|
||||
|
||||
The SDK decouples editing sessions from storage and preview surfaces through two injectable interfaces: `PersistAdapter` and `PreviewAdapter`. Both ship with concrete factory functions you pass to `openComposition()`. You can also implement either interface directly for custom storage backends (S3, IndexedDB, HTTP) or custom preview surfaces.
|
||||
|
||||
## PersistAdapter
|
||||
|
||||
```typescript
|
||||
import type { PersistAdapter } from "@hyperframes/sdk";
|
||||
```
|
||||
|
||||
Injectable storage adapter. Decouples the SDK from the underlying persistence mechanism so the same session code runs in tests (memory), local dev (filesystem), and production (cloud storage).
|
||||
|
||||
### Interface
|
||||
|
||||
```typescript
|
||||
interface PersistAdapter {
|
||||
read(path: string): Promise<string | undefined>;
|
||||
write(path: string, content: string): Promise<void>;
|
||||
flush(): Promise<void>;
|
||||
listVersions(path: string): Promise<PersistVersionEntry[]>;
|
||||
loadFrom(path: string, versionKey: string): Promise<string | undefined>;
|
||||
on(event: "persist:error", handler: (event: PersistErrorEvent) => void): () => void;
|
||||
}
|
||||
```
|
||||
|
||||
<ParamField path="read" type="(path: string) => Promise<string | undefined>">
|
||||
Returns the stored content for `path`, or `undefined` for a path that has never been written. Never throws for a missing path.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="write" type="(path: string, content: string) => Promise<void>">
|
||||
Persists `content` at `path`. Idempotent — a second call with the same path overwrites the prior value. Write failures must not propagate as thrown exceptions; fire `persist:error` instead.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="flush" type="() => Promise<void>">
|
||||
Forces any queued or in-flight writes to commit before resolving. Call before process exit or navigation to prevent data loss.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="listVersions" type="(path: string) => Promise<PersistVersionEntry[]>">
|
||||
Returns the version history for `path` ordered newest-first. Returns an empty array when no versions exist. See `PersistVersionEntry` below.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="loadFrom" type="(path: string, versionKey: string) => Promise<string | undefined>">
|
||||
Returns the HTML content for a specific version identified by `versionKey`. Returns `undefined` when the key does not exist.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="on" type='(event: "persist:error", handler) => () => void'>
|
||||
Subscribes to write failures. Returns an unsubscribe function. Adapters must emit this event — not throw — when a write fails, so the session continues running even when storage is temporarily unavailable.
|
||||
</ParamField>
|
||||
|
||||
### Contract summary
|
||||
|
||||
- `read()` returns `undefined` for a path that has never been written — never throws ENOENT or a 404 equivalent.
|
||||
- `write()` is idempotent; a second write to the same path replaces the stored content.
|
||||
- `flush()` resolves when any pending writes are committed to durable storage.
|
||||
- `listVersions()` returns entries newest-first; `loadFrom()` uses the keys from those entries.
|
||||
- Write errors are emitted via `on('persist:error')`, never thrown — the session keeps running.
|
||||
|
||||
### PersistVersionEntry
|
||||
|
||||
```typescript
|
||||
interface PersistVersionEntry {
|
||||
/** Opaque key identifying this version (adapter-defined format). */
|
||||
key: string;
|
||||
/** Full HTML content — may be omitted by adapters that load content lazily via loadFrom(). */
|
||||
content?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
```
|
||||
|
||||
The `key` is adapter-defined and opaque to callers — pass it directly to `loadFrom()`. The filesystem adapter encodes milliseconds and a counter into the key; the memory adapter uses an incrementing `"v1"`, `"v2"` … scheme.
|
||||
|
||||
---
|
||||
|
||||
## PreviewAdapter
|
||||
|
||||
```typescript
|
||||
import type { PreviewAdapter } from "@hyperframes/sdk";
|
||||
```
|
||||
|
||||
Injectable preview surface adapter. Decouples the SDK from the host's rendering layer. The SDK is **not** in the 60fps draft loop: your pointer-move handler calls `applyDraft()` directly on the adapter at 60fps, and the SDK only gets involved once per gesture when `commitPreview()` fires to derive and dispatch the resulting op.
|
||||
|
||||
### Interface
|
||||
|
||||
```typescript
|
||||
interface PreviewAdapter {
|
||||
elementAtPoint(x: number, y: number, opts?: { atTime?: number }): ElementAtPointResult | null;
|
||||
applyDraft(id: string, props: DraftProps): void;
|
||||
commitPreview(): void;
|
||||
cancelPreview(): void;
|
||||
select(ids: string[], opts?: { additive?: boolean }): void;
|
||||
on(event: "selection", handler: (ids: string[]) => void): () => void;
|
||||
}
|
||||
```
|
||||
|
||||
<ParamField path="elementAtPoint" type="(x, y, opts?) => ElementAtPointResult | null">
|
||||
Synchronous hit-test at composition coordinates `(x, y)`. Returns the nearest `[data-hf-id]` element under the point, or `null` for a transparent hit (the composition root, an opacity-0 element, or nothing at all). Requires a same-origin iframe — cross-origin access throws a DOMException. The `atTime` option reflects GSAP state at the current playhead; seeking to a speculative time is not supported.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="applyDraft" type="(id: string, props: DraftProps) => void">
|
||||
Visually translates the preview element at 60fps during a drag: sets the element's CSS `translate` to its pre-drag value composed with the accumulated delta. Works on GSAP-animated elements (a `translate` set after GSAP's first parse composes with the animated transform). The **SDK is not called here** — this is a direct write to the preview surface by your pointer-move handler. Switching `id` mid-drag reverts the previous element's draft first.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="commitPreview" type="() => void">
|
||||
Called once on pointer-up. Reads the accumulated draft delta, derives a `moveElement` op from it, dispatches it into the SDK, emits a patch event, and mirrors the committed position onto the live element (so it holds without a reload). This is the only moment the SDK becomes aware of a drag. If dispatch throws, the draft translate is reverted and the error propagates.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="cancelPreview" type="() => void">
|
||||
Restores the element's pre-drag `translate` without dispatching any op. The model is never changed. Call this on `Escape` keydown or when a drag is aborted.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="select" type="(ids: string[], opts?: { additive?: boolean }) => void">
|
||||
Sets the preview selection and fires `selectionchange` on the session. Pass `{ additive: true }` to merge `ids` into the current selection rather than replacing it.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="on" type='(event: "selection", handler: (ids: string[]) => void) => () => void'>
|
||||
Fired when the preview host changes the selection (for example, the user clicks an element). Returns an unsubscribe function. In the current release, callers listen to the session's own `selectionchange` event instead — this hook is wired in a future stage.
|
||||
</ParamField>
|
||||
|
||||
### ElementAtPointResult
|
||||
|
||||
```typescript
|
||||
interface ElementAtPointResult {
|
||||
id: string;
|
||||
tag: string;
|
||||
}
|
||||
```
|
||||
|
||||
The `id` is the element's `data-hf-id` value; `tag` is its lowercase tag name (e.g. `"div"`, `"img"`).
|
||||
|
||||
### DraftProps
|
||||
|
||||
```typescript
|
||||
interface DraftProps {
|
||||
dx?: number;
|
||||
dy?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
```
|
||||
|
||||
`dx` and `dy` are the accumulated drag deltas in composition pixels. `width` and `height` are defined in the interface for forward compatibility but are not yet wired to any op.
|
||||
|
||||
<Note>
|
||||
`ElementAtPointResult` and `DraftProps` are the structural shapes a `PreviewAdapter` produces and consumes. They are **not** re-exported from the `@hyperframes/sdk` barrel — you implement against these shapes rather than importing them.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Factory Functions
|
||||
|
||||
### createMemoryAdapter
|
||||
|
||||
```typescript
|
||||
import { createMemoryAdapter } from "@hyperframes/sdk";
|
||||
|
||||
function createMemoryAdapter(): PersistAdapter & { injectFault(message: string): void };
|
||||
```
|
||||
|
||||
Returns a `PersistAdapter` backed by an in-process `Map`. Writes are synchronous; `flush()` is a no-op. Versions are keyed `"v1"`, `"v2"` … and stored in memory with full content.
|
||||
|
||||
The returned value also exposes `injectFault(message)` — a test helper that causes the **next** `write()` call to fire a `persist:error` event with `message` instead of committing. Use this in unit tests to verify your error-handling code path.
|
||||
|
||||
```typescript
|
||||
const persist = createMemoryAdapter();
|
||||
|
||||
const comp = await openComposition(html, { persist });
|
||||
comp.setText("hf-title", "Hello");
|
||||
await comp.flush();
|
||||
|
||||
const saved = await persist.read("composition.html");
|
||||
```
|
||||
|
||||
<Note>
|
||||
`createMemoryAdapter()` is best suited for tests, demos, and ephemeral in-process sessions. For local development, use `createFsAdapter()` so edits survive restarts.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
### createFsAdapter
|
||||
|
||||
```typescript
|
||||
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
|
||||
|
||||
function createFsAdapter(opts: FsAdapterOptions): PersistAdapter;
|
||||
```
|
||||
|
||||
**Node.js only.** Returns a `PersistAdapter` that reads and writes files under a root directory. Import from the `@hyperframes/sdk/adapters/fs` subpath — this module uses Node `fs/promises` and is excluded from the browser-safe main bundle.
|
||||
|
||||
#### FsAdapterOptions
|
||||
|
||||
```typescript
|
||||
interface FsAdapterOptions {
|
||||
/** Root directory for composition files. */
|
||||
root: string;
|
||||
/** Max versions to keep per file. Default: 20. */
|
||||
maxVersions?: number;
|
||||
}
|
||||
```
|
||||
|
||||
<ParamField path="root" type="string" required>
|
||||
Absolute or relative path to the directory where composition files are written. Created with `mkdir -p` on first write.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="maxVersions" type="number">
|
||||
Maximum number of historical versions retained per file. Oldest versions are pruned automatically when the limit is exceeded. Defaults to `20`.
|
||||
</ParamField>
|
||||
|
||||
The adapter writes the current composition at `{root}/{path}` and stores version snapshots in `{root}/.hf-versions/{path}/`. Version keys encode `Date.now()` and a monotonic counter (`"1750000000000-0001"`), so `listVersions()` returns them newest-first by lexicographic descending sort.
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
|
||||
|
||||
const comp = await openComposition(html, {
|
||||
persist: createFsAdapter({ root: "./project", maxVersions: 50 }),
|
||||
persistPath: "index.html",
|
||||
});
|
||||
|
||||
comp.setText("hf-title", "Saved");
|
||||
await comp.flush();
|
||||
|
||||
// List saved versions
|
||||
const adapter = createFsAdapter({ root: "./project" });
|
||||
const versions = await adapter.listVersions("index.html");
|
||||
const previous = await adapter.loadFrom("index.html", versions[1].key);
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`createFsAdapter` uses Node.js `fs/promises`. Do not import it in browser or edge environments — import from `@hyperframes/sdk/adapters/fs` (the subpath) so bundlers can tree-shake it.
|
||||
</Warning>
|
||||
|
||||
---
|
||||
|
||||
### createHeadlessAdapter
|
||||
|
||||
```typescript
|
||||
import { createHeadlessAdapter } from "@hyperframes/sdk";
|
||||
|
||||
function createHeadlessAdapter(): PreviewAdapter;
|
||||
```
|
||||
|
||||
Returns a no-op `PreviewAdapter` for headless use: agents, CI pipelines, and server-side rendering. All methods are stubs — `elementAtPoint` always returns `null`, `applyDraft` and `commitPreview` are no-ops, and the `"selection"` event never fires.
|
||||
|
||||
Pass this adapter when you open a composition for programmatic editing and do not need a live preview surface.
|
||||
|
||||
```typescript
|
||||
import { openComposition, createHeadlessAdapter } from "@hyperframes/sdk";
|
||||
|
||||
const comp = await openComposition(html, {
|
||||
preview: createHeadlessAdapter(),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
`openComposition` defaults to a headless preview adapter when none is supplied, so you rarely need to pass it explicitly. The main use case is making the intent clear in code that runs in both headless and browser environments.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
### createIframePreviewAdapter
|
||||
|
||||
```typescript
|
||||
import { createIframePreviewAdapter } from "@hyperframes/sdk";
|
||||
|
||||
function createIframePreviewAdapter(
|
||||
iframe: HTMLIFrameElement,
|
||||
dispatch?: (op: EditOp) => void,
|
||||
): PreviewAdapter;
|
||||
```
|
||||
|
||||
Returns a `PreviewAdapter` that bridges the SDK to a same-origin `<iframe>` containing the composition. Provides real hit-testing via `elementsFromPoint` (z-stack aware), draft drag support, and selection management.
|
||||
|
||||
**Requirements:**
|
||||
- The iframe must be same-origin (e.g. a `srcdoc` or `blob:` URL). Cross-origin access to `contentDocument` throws a `DOMException`.
|
||||
- Pass your session's `dispatch` callback to enable `commitPreview()` — without it, pointer-up is a no-op on the model.
|
||||
|
||||
**Image-alpha hit-testing:** For `<img>` elements, the adapter samples the alpha channel of the pixel under the pointer using an `OffscreenCanvas`. Transparent pixels fall through to the element behind. Cross-origin images that taint the canvas are treated as opaque (safe fallback, logged once per src).
|
||||
|
||||
```typescript
|
||||
import { openComposition, createIframePreviewAdapter } from "@hyperframes/sdk";
|
||||
|
||||
const iframe = document.querySelector<HTMLIFrameElement>("#preview-frame")!;
|
||||
|
||||
const comp = await openComposition(html);
|
||||
|
||||
const preview = createIframePreviewAdapter(iframe, (op) => comp.dispatch(op));
|
||||
|
||||
// Hit-test at pointer position
|
||||
const hit = preview.elementAtPoint(pointerX, pointerY);
|
||||
if (hit) {
|
||||
preview.select([hit.id]);
|
||||
}
|
||||
|
||||
// Drag: call applyDraft at 60fps, commitPreview on pointer-up
|
||||
preview.applyDraft(hit.id, { dx: 12, dy: -5 });
|
||||
preview.commitPreview();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Export Map
|
||||
|
||||
| Symbol | Imported from |
|
||||
|--------|---------------|
|
||||
| `PersistAdapter`, `PreviewAdapter`, `PersistVersionEntry` | `@hyperframes/sdk` (types only) |
|
||||
| `createMemoryAdapter` | `@hyperframes/sdk` |
|
||||
| `createHeadlessAdapter` | `@hyperframes/sdk` |
|
||||
| `createIframePreviewAdapter`, `resolveNearestHfElement` | `@hyperframes/sdk` |
|
||||
| `createFsAdapter`, `FsAdapterOptions` | `@hyperframes/sdk/adapters/fs` |
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Persistence Guide" icon="floppy-disk" href="/sdk/guides/persistence">
|
||||
How to wire adapters into openComposition, handle errors, and restore versions.
|
||||
</Card>
|
||||
<Card title="Canvas Integration" icon="browser" href="/sdk/guides/canvas-integration">
|
||||
Building a visual editor canvas with the iframe preview adapter and hit-testing.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
---
|
||||
title: "Composition"
|
||||
description: "The main editing surface returned by openComposition — query, mutate, animate, and serialize a composition."
|
||||
---
|
||||
|
||||
`Composition` is the object returned by [`openComposition`](/sdk/reference/open-composition). Every SDK edit goes through this interface. Typed methods are convenience wrappers over `dispatch()`; all validation runs in `dispatch()` regardless of which entry point you use.
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
|
||||
const comp: Composition = await openComposition(html);
|
||||
```
|
||||
|
||||
For a practical walkthrough see [Querying and editing](/sdk/guides/querying-and-editing).
|
||||
|
||||
---
|
||||
|
||||
## Typed edit methods
|
||||
|
||||
Typed methods are the recommended way to apply common edits. Each one calls `dispatch()` internally, so all patch events, history, and persistence behavior is identical.
|
||||
|
||||
### `setStyle`
|
||||
|
||||
```typescript
|
||||
setStyle(id: HfId, styles: Record<string, string | null>): void
|
||||
```
|
||||
|
||||
Apply inline CSS styles to one element. Use camelCase property names (matching `CSSStyleDeclaration`). Pass `null` as a value to remove that property.
|
||||
|
||||
```typescript
|
||||
comp.setStyle("hf-title", { color: "#FFD60A", fontSize: "96px" });
|
||||
comp.setStyle("hf-card", { borderRadius: null }); // removes border-radius
|
||||
```
|
||||
|
||||
### `setText`
|
||||
|
||||
```typescript
|
||||
setText(id: HfId, value: string): void
|
||||
```
|
||||
|
||||
Replace the display text of an element. Targets the element's direct text content, not all descendant text.
|
||||
|
||||
```typescript
|
||||
comp.setText("hf-headline", "Product launch — June 2026");
|
||||
```
|
||||
|
||||
### `setAttribute`
|
||||
|
||||
```typescript
|
||||
setAttribute(id: HfId, name: string, value: string | null): void
|
||||
```
|
||||
|
||||
Set or remove an HTML attribute. Pass `null` to remove the attribute entirely.
|
||||
|
||||
```typescript
|
||||
comp.setAttribute("hf-logo", "src", "/assets/logo-v2.png");
|
||||
comp.setAttribute("hf-video", "autoplay", null); // removes autoplay
|
||||
```
|
||||
|
||||
### `setTiming`
|
||||
|
||||
```typescript
|
||||
setTiming(id: HfId, timing: { start?: number; duration?: number; trackIndex?: number }): void
|
||||
```
|
||||
|
||||
Update the `data-start`, `data-duration`, and/or `data-track` attributes of one element. All fields are optional; omitted fields are left unchanged.
|
||||
|
||||
```typescript
|
||||
comp.setTiming("hf-title", { start: 0.5, duration: 2.5 });
|
||||
comp.setTiming("hf-cta", { trackIndex: 1 });
|
||||
```
|
||||
|
||||
### `removeElement`
|
||||
|
||||
```typescript
|
||||
removeElement(id: HfId): void
|
||||
```
|
||||
|
||||
Remove an element and all its descendants from the composition. The inverse is an `addElement` of the removed subtree, so `undo()` restores it exactly.
|
||||
|
||||
```typescript
|
||||
comp.removeElement("hf-old-badge");
|
||||
```
|
||||
|
||||
### `addElement`
|
||||
|
||||
```typescript
|
||||
addElement(parent: HfId | null, index: number, html: string): HfId
|
||||
```
|
||||
|
||||
Insert an HTML fragment as a child of `parent` at zero-based sibling `index`. Pass `null` for `parent` to insert at the document body root. The fragment must be single-root and must not contain `<script>` tags. Returns the minted `hf-id` of the inserted root element.
|
||||
|
||||
```typescript
|
||||
const newId = comp.addElement("hf-scene", 0, '<div class="clip">New clip</div>');
|
||||
comp.setText(newId, "Inserted clip");
|
||||
```
|
||||
|
||||
### `setVariableValue`
|
||||
|
||||
```typescript
|
||||
setVariableValue(id: string, value: string | number | boolean | FontValue | ImageValue): void
|
||||
```
|
||||
|
||||
Update a composition variable. Font variables require a `FontValue` object `{ name, source }`; image variables require an `ImageValue` object `{ url, alt?, fit? }`. Scalar variables accept `string | number | boolean`.
|
||||
|
||||
```typescript
|
||||
comp.setVariableValue("brandColor", "#6C5CE7");
|
||||
comp.setVariableValue("brandFont", { name: "Inter", source: "https://fonts.googleapis.com/css2?family=Inter" });
|
||||
comp.setVariableValue("heroImage", { url: "/assets/hero.jpg", fit: "cover" });
|
||||
```
|
||||
|
||||
### `getElementTimings`
|
||||
|
||||
```typescript
|
||||
getElementTimings(): Record<HfId, ElementTimingSnapshot>
|
||||
```
|
||||
|
||||
Return a map of enter/exit times and active GSAP labels for every timed element. The SDK derives `enterAt`/`exitAt` using `data-duration` when present, falling back to `data-end − data-start`. GSAP labels are parsed fresh from the script each call. Elements with no timing attributes are excluded.
|
||||
|
||||
```typescript
|
||||
const timings = comp.getElementTimings();
|
||||
for (const [id, t] of Object.entries(timings)) {
|
||||
console.log(id, t.enterAt, t.exitAt, t.labels);
|
||||
}
|
||||
```
|
||||
|
||||
### `setElementTiming`
|
||||
|
||||
```typescript
|
||||
setElementTiming(map: Record<HfId, { start?: number; duration?: number; trackIndex?: number }>): void
|
||||
```
|
||||
|
||||
Apply a sparse timing map in one batch. All entries are dispatched inside a single `batch()` call so the history sees one undo step. Entries for unknown IDs are silently skipped.
|
||||
|
||||
```typescript
|
||||
comp.setElementTiming({
|
||||
"hf-title": { start: 0, duration: 2 },
|
||||
"hf-caption": { start: 1.5, duration: 1.5 },
|
||||
"hf-cta": { start: 3, duration: 2 },
|
||||
});
|
||||
```
|
||||
|
||||
### `setHold`
|
||||
|
||||
```typescript
|
||||
setHold(id: HfId, hold: ElasticHold): void
|
||||
```
|
||||
|
||||
Set an elastic hold window — a `{ start, end, fill }` range that either freezes or loops the element during a pause in the timeline.
|
||||
|
||||
```typescript
|
||||
comp.setHold("hf-hero", { start: 2, end: 5, fill: "freeze" });
|
||||
```
|
||||
|
||||
### `addGsapTween`
|
||||
|
||||
```typescript
|
||||
addGsapTween(target: HfId, tween: GsapTweenSpec): string
|
||||
```
|
||||
|
||||
Add a GSAP tween to the composition's timeline targeting the given element. Returns the newly assigned `animationId`.
|
||||
|
||||
```typescript
|
||||
const animId = comp.addGsapTween("hf-title", {
|
||||
method: "from",
|
||||
position: 0,
|
||||
duration: 0.6,
|
||||
ease: "power3.out",
|
||||
fromProperties: { opacity: 0, y: 40 },
|
||||
});
|
||||
```
|
||||
|
||||
### `setGsapTween`
|
||||
|
||||
```typescript
|
||||
setGsapTween(animationId: string, properties: Partial<GsapTweenSpec>): void
|
||||
```
|
||||
|
||||
Update properties on an existing tween. Only the fields you supply are changed; the rest remain.
|
||||
|
||||
```typescript
|
||||
comp.setGsapTween(animId, { ease: "back.out(1.7)", duration: 0.8 });
|
||||
```
|
||||
|
||||
### `removeGsapTween`
|
||||
|
||||
```typescript
|
||||
removeGsapTween(animationId: string): void
|
||||
```
|
||||
|
||||
Remove a tween by animation ID.
|
||||
|
||||
```typescript
|
||||
comp.removeGsapTween(animId);
|
||||
```
|
||||
|
||||
### `addWithKeyframes`
|
||||
|
||||
```typescript
|
||||
addWithKeyframes(
|
||||
targetSelector: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
keyframes: KeyframeSpec[],
|
||||
ease?: string,
|
||||
): string
|
||||
```
|
||||
|
||||
Add a new keyframed tween for `targetSelector` at the given timeline position and duration. Returns the newly minted `animationId`. Position is a number in seconds (not a label-relative string).
|
||||
|
||||
```typescript
|
||||
const animId = comp.addWithKeyframes(
|
||||
"#hf-logo",
|
||||
0.5,
|
||||
1.2,
|
||||
[
|
||||
{ percentage: 0, properties: { opacity: 0, scale: 0.8 } },
|
||||
{ percentage: 100, properties: { opacity: 1, scale: 1 } },
|
||||
],
|
||||
"power2.out",
|
||||
);
|
||||
```
|
||||
|
||||
### `replaceWithKeyframes`
|
||||
|
||||
```typescript
|
||||
replaceWithKeyframes(
|
||||
animationId: string,
|
||||
targetSelector: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
keyframes: KeyframeSpec[],
|
||||
ease?: string,
|
||||
): string
|
||||
```
|
||||
|
||||
Atomically remove an existing tween and add a replacement keyframed tween. Returns the replacement's `animationId`. Because position-derived IDs renumber after the removal, the returned ID may differ from the input `animationId` — treat the return value as the new canonical ID.
|
||||
|
||||
```typescript
|
||||
const newId = comp.replaceWithKeyframes(
|
||||
oldAnimId,
|
||||
"#hf-card",
|
||||
1.0,
|
||||
0.8,
|
||||
[
|
||||
{ percentage: 0, properties: { x: -100 } },
|
||||
{ percentage: 100, properties: { x: 0 } },
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
### `undo` / `redo`
|
||||
|
||||
```typescript
|
||||
undo(): void
|
||||
redo(): void
|
||||
```
|
||||
|
||||
Step backward or forward through the undo history. No-ops when history is disabled (`history: false` in options) or in embedded mode.
|
||||
|
||||
```typescript
|
||||
comp.setText("hf-title", "Draft");
|
||||
comp.undo(); // reverts to original text
|
||||
comp.redo(); // re-applies "Draft"
|
||||
```
|
||||
|
||||
### `canUndo` / `canRedo`
|
||||
|
||||
```typescript
|
||||
canUndo(): boolean
|
||||
canRedo(): boolean
|
||||
```
|
||||
|
||||
Return `true` when a step in that direction is available. Use to drive the enabled state of undo/redo UI controls.
|
||||
|
||||
```typescript
|
||||
undoButton.disabled = !comp.canUndo();
|
||||
redoButton.disabled = !comp.canRedo();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query
|
||||
|
||||
### `getElements`
|
||||
|
||||
```typescript
|
||||
getElements(): ElementSnapshot[]
|
||||
```
|
||||
|
||||
Return a flat array of all elements in the composition, including elements nested inside sub-compositions. Each `ElementSnapshot` carries the element's `id`, `scopedId`, `tag`, `inlineStyles`, `classNames`, `attributes`, `text`, timing fields, and `animationIds`. The array is a fresh copy; mutations to the returned objects have no effect.
|
||||
|
||||
```typescript
|
||||
const elements = comp.getElements();
|
||||
const images = elements.filter((el) => el.tag === "img");
|
||||
```
|
||||
|
||||
<Note>
|
||||
For elements inside inlined sub-compositions, use `scopedId` (e.g. `"hf-host/hf-leaf"`) as the dispatch target, not `id`. Top-level elements have `scopedId === id`.
|
||||
</Note>
|
||||
|
||||
### `getElement`
|
||||
|
||||
```typescript
|
||||
getElement(id: HfId): ElementSnapshot | null
|
||||
```
|
||||
|
||||
Return the snapshot for one element by ID. Accepts both bare IDs (top-level elements) and scoped IDs (sub-composition elements). Returns `null` if no element matches.
|
||||
|
||||
```typescript
|
||||
const el = comp.getElement("hf-title");
|
||||
if (el) {
|
||||
console.log(el.tag, el.inlineStyles);
|
||||
}
|
||||
```
|
||||
|
||||
### `find`
|
||||
|
||||
```typescript
|
||||
find(query: FindQuery): string[]
|
||||
```
|
||||
|
||||
Search elements by structured query. Returns an array of `scopedId` strings for matching elements. All fields in `FindQuery` are optional and combined with AND logic.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `tag` | `string` | Exact HTML tag name, lowercase (`"div"`, `"img"`). |
|
||||
| `text` | `string` | Substring match against the element's `text` field. |
|
||||
| `name` | `string` | Exact match against `data-name` attribute. |
|
||||
| `track` | `number` | Exact track index (`data-track`). |
|
||||
| `composition` | `string` | Filter to elements inside a specific sub-composition host by its `hf-id`. |
|
||||
|
||||
```typescript
|
||||
const headlineIds = comp.find({ name: "headline" });
|
||||
const allImages = comp.find({ tag: "img" });
|
||||
const track1Ids = comp.find({ track: 1 });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Selection
|
||||
|
||||
### `selection`
|
||||
|
||||
```typescript
|
||||
selection(): SelectionProxy
|
||||
```
|
||||
|
||||
Return a `SelectionProxy` that resolves `getSelection()` at call time and dispatches operations to all selected elements. The proxy captures the ID list when you call `selection()` — subsequent selection changes do not affect an already-captured proxy.
|
||||
|
||||
```typescript
|
||||
const sel = comp.selection();
|
||||
sel.setStyle({ opacity: "0.5" });
|
||||
sel.removeElement(); // removes all currently selected elements
|
||||
```
|
||||
|
||||
### `element`
|
||||
|
||||
```typescript
|
||||
element(id: HfId): ElementHandle
|
||||
```
|
||||
|
||||
Return a curried `ElementHandle` for a single element. The handle stores only the ID string, so there is no stale-reference hazard if the DOM changes between calls.
|
||||
|
||||
```typescript
|
||||
const title = comp.element("hf-title");
|
||||
title.setText("Hello");
|
||||
title.setStyle({ fontWeight: "700" });
|
||||
```
|
||||
|
||||
### `getSelection`
|
||||
|
||||
```typescript
|
||||
getSelection(): string[]
|
||||
```
|
||||
|
||||
Return a copy of the current selection as an array of `hf-id` strings. Returns `[]` when nothing is selected.
|
||||
|
||||
```typescript
|
||||
const ids = comp.getSelection();
|
||||
console.log(`${ids.length} elements selected`);
|
||||
```
|
||||
|
||||
### `setSelection`
|
||||
|
||||
```typescript
|
||||
setSelection(ids: string[]): void
|
||||
```
|
||||
|
||||
Replace the current selection. Fires `selectionchange`. Pass `[]` to clear the selection. Duplicate IDs are deduplicated automatically.
|
||||
|
||||
```typescript
|
||||
comp.setSelection(["hf-title", "hf-subtitle"]);
|
||||
comp.setSelection([]); // clear
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced / agent
|
||||
|
||||
### `dispatch`
|
||||
|
||||
```typescript
|
||||
dispatch(op: EditOp, opts?: { origin?: unknown }): void
|
||||
```
|
||||
|
||||
Apply a data-shaped edit operation. All typed methods call this internally. Use `dispatch` when you need to apply op types that do not have a typed wrapper, when you are building automation that constructs ops programmatically, or when you need to set a custom `origin`.
|
||||
|
||||
```typescript
|
||||
comp.dispatch({ type: "setStyle", target: "hf-card", styles: { borderRadius: "24px" } });
|
||||
comp.dispatch({ type: "reorderElements", entries: [{ target: "hf-bg", zIndex: 0 }] });
|
||||
comp.dispatch({ type: "setCompositionMetadata", width: 1920, height: 1080, duration: 30 });
|
||||
```
|
||||
|
||||
For the full op catalog see [Edit operations](/sdk/reference/edit-operations).
|
||||
|
||||
You can also target multiple elements with a single `dispatch` by passing an array to `target`:
|
||||
|
||||
```typescript
|
||||
comp.dispatch({ type: "setText", target: ["hf-title", "hf-subtitle"], value: "Coming soon" });
|
||||
```
|
||||
|
||||
### `batch`
|
||||
|
||||
```typescript
|
||||
batch(fn: () => void, opts?: { origin?: unknown }): void
|
||||
```
|
||||
|
||||
Coalesce multiple dispatches into one undo entry and one `patch` event. If the callback throws, all DOM mutations that ran inside the batch are rolled back atomically and the model is restored to its state before `batch()` was called.
|
||||
|
||||
```typescript
|
||||
comp.batch(() => {
|
||||
comp.setText("hf-title", "Version 2");
|
||||
comp.setStyle("hf-title", { color: "#22C55E" });
|
||||
comp.setTiming("hf-title", { start: 1, duration: 3 });
|
||||
});
|
||||
```
|
||||
|
||||
### `can`
|
||||
|
||||
```typescript
|
||||
can(op: EditOp): CanResult
|
||||
```
|
||||
|
||||
Dry-run validation. Returns `{ ok: true }` when `dispatch(op)` would succeed, or `{ ok: false, code, message, hint? }` when it would fail or be a no-op.
|
||||
|
||||
Use this as a feature-detection gate before rendering controls that depend on GSAP timeline availability, or before showing UI that applies optional edits.
|
||||
|
||||
```typescript
|
||||
const result = comp.can({
|
||||
type: "setGsapTween",
|
||||
animationId: "anim-1",
|
||||
properties: { ease: "power3.out" },
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
comp.setGsapTween("anim-1", { ease: "power3.out" });
|
||||
} else {
|
||||
console.warn(result.code, result.message);
|
||||
}
|
||||
```
|
||||
|
||||
Stable error codes: `E_TARGET_NOT_FOUND`, `E_NO_ROOT`, `E_NO_GSAP_TIMELINE`, `E_NO_GSAP_SCRIPT`.
|
||||
|
||||
For the full op catalog see [Edit operations](/sdk/reference/edit-operations).
|
||||
|
||||
---
|
||||
|
||||
## Events
|
||||
|
||||
All `on()` overloads return an unsubscribe function. Call it to remove the listener.
|
||||
|
||||
```typescript
|
||||
on(event: "change", handler: () => void): () => void
|
||||
on(event: "selectionchange", handler: (ids: string[]) => void): () => void
|
||||
on(event: "patch", handler: (event: PatchEvent) => void): () => void
|
||||
on(event: "persist:error", handler: (event: PersistErrorEvent) => void): () => void
|
||||
```
|
||||
|
||||
### `change`
|
||||
|
||||
Fires after every committed mutation (whether typed method, `dispatch`, or `batch`). Use this to refresh a canvas or other derived view.
|
||||
|
||||
```typescript
|
||||
const off = comp.on("change", () => {
|
||||
canvas.render(comp.getElements());
|
||||
});
|
||||
|
||||
off(); // unsubscribe
|
||||
```
|
||||
|
||||
### `selectionchange`
|
||||
|
||||
Fires when the selection changes, with the new ID array as the argument.
|
||||
|
||||
```typescript
|
||||
comp.on("selectionchange", (ids) => {
|
||||
propertiesPanel.show(ids);
|
||||
});
|
||||
```
|
||||
|
||||
### `patch`
|
||||
|
||||
Fires after every committed change with a `PatchEvent` containing RFC 6902 forward and inverse patches, the `origin`, and semantic `opTypes`. Use this to mirror edits into a host history stack, collaboration layer, or audit log.
|
||||
|
||||
```typescript
|
||||
comp.on("patch", ({ patches, inversePatches, origin, opTypes }) => {
|
||||
if (origin !== ORIGIN_APPLY_PATCHES) {
|
||||
hostHistory.push({ patches, inversePatches });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Always guard against `ORIGIN_APPLY_PATCHES` in `patch` listeners. Failing to skip that origin causes an infinite loop when your host replays inverse patches back into the SDK via `applyPatches()`.
|
||||
</Warning>
|
||||
|
||||
### `persist:error`
|
||||
|
||||
Fires when the persist adapter fails to write. The session continues; edits accumulate in memory and the queue retries on the next mutation.
|
||||
|
||||
```typescript
|
||||
comp.on("persist:error", ({ error }) => {
|
||||
toast.error(`Autosave failed: ${error.message}`);
|
||||
});
|
||||
```
|
||||
|
||||
For undo/redo and patch patterns see [Undo, redo, and patches](/sdk/guides/undo-redo-and-patches).
|
||||
|
||||
---
|
||||
|
||||
## Serialization
|
||||
|
||||
### `serialize`
|
||||
|
||||
```typescript
|
||||
serialize(): string
|
||||
```
|
||||
|
||||
Return the current composition as an HTML string reflecting all mutations applied since `openComposition`. The base HTML is never modified; this always returns a fresh serialization of the live document.
|
||||
|
||||
```typescript
|
||||
const updatedHtml = comp.serialize();
|
||||
await fs.writeFile("output.html", updatedHtml);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Embedded extras
|
||||
|
||||
These methods are only meaningful in embedded / override mode. See the [embedded override mode guide](/sdk/guides/embedded-override-mode).
|
||||
|
||||
### `getOverrides`
|
||||
|
||||
```typescript
|
||||
getOverrides(): OverrideSet
|
||||
```
|
||||
|
||||
Return a copy of the current override set — the sparse delta accumulated on top of the base template. Store this instead of the full HTML when the base template is shared.
|
||||
|
||||
```typescript
|
||||
const delta = comp.getOverrides();
|
||||
await db.saveUserOverrides(userId, delta);
|
||||
```
|
||||
|
||||
### `applyPatches`
|
||||
|
||||
```typescript
|
||||
applyPatches(patches: readonly JsonPatchOp[], opts?: { origin?: unknown }): void
|
||||
```
|
||||
|
||||
Apply RFC 6902 patches directly to the live document. The default origin is `ORIGIN_APPLY_PATCHES`, which prevents `patch` listeners from forwarding these back in an undo loop. Use this when the host owns the undo stack and needs to replay inverse patches from its own history.
|
||||
|
||||
```typescript
|
||||
// Host undo: replay inverse patches from the host stack
|
||||
const { inversePatches } = hostHistory.pop();
|
||||
comp.applyPatches(inversePatches);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle
|
||||
|
||||
### `flush`
|
||||
|
||||
```typescript
|
||||
flush(): Promise<void>
|
||||
```
|
||||
|
||||
Drain the persist queue. Resolves when any pending write has been committed to the adapter. No-op when no persist adapter was provided. Call this before process exit or navigation away to avoid losing queued writes.
|
||||
|
||||
```typescript
|
||||
comp.setText("hf-title", "Final title");
|
||||
await comp.flush();
|
||||
comp.dispose();
|
||||
```
|
||||
|
||||
### `dispose`
|
||||
|
||||
```typescript
|
||||
dispose(): void
|
||||
```
|
||||
|
||||
Tear down the session: unsubscribe preview adapter listeners, stop the persist queue, stop the history module, and clear all event handlers. After `dispose()`, the `Composition` object is inert; continued calls produce no-ops or errors.
|
||||
|
||||
```typescript
|
||||
comp.dispose();
|
||||
```
|
||||
|
||||
<Note>
|
||||
Always call `dispose()` when you are done with a session. Skipping it leaks listeners attached to the preview adapter.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="openComposition" icon="door-open" href="/sdk/reference/open-composition">
|
||||
Session factory — all options and modes.
|
||||
</Card>
|
||||
<Card title="Edit operations" icon="bolt" href="/sdk/reference/edit-operations">
|
||||
Full op catalog for dispatch() and can().
|
||||
</Card>
|
||||
<Card title="Types reference" icon="file-code" href="/sdk/reference/types">
|
||||
All exported TypeScript types — ElementSnapshot, PatchEvent, GsapTweenSpec, and more.
|
||||
</Card>
|
||||
<Card title="Querying and editing" icon="magnifying-glass" href="/sdk/guides/querying-and-editing">
|
||||
Practical guide to find, getElements, and typed edits.
|
||||
</Card>
|
||||
<Card title="Undo, redo, and patches" icon="arrow-uturn-left" href="/sdk/guides/undo-redo-and-patches">
|
||||
History, patch events, ORIGIN_APPLY_PATCHES guard.
|
||||
</Card>
|
||||
<Card title="Embedded override mode" icon="layers" href="/sdk/guides/embedded-override-mode">
|
||||
Template-driven products with host-owned history.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
---
|
||||
title: "Edit Operations"
|
||||
description: "The complete EditOp catalog for dispatch(), can(), and batch()."
|
||||
---
|
||||
|
||||
Every mutation in the SDK is expressed as an `EditOp` — a plain data object with a discriminated `type` field. You can submit ops individually through `dispatch()`, validate them with `can()`, or group them into a single undo/persist step with `batch()`.
|
||||
|
||||
<Tip>
|
||||
Every element op requires an explicit `target` (an `HfId` string or `HfId[]` array). There is no selection-implicit mutation — the SDK never reads the current selection to decide what to edit. The typed methods on `Composition` (such as `comp.setText()` and `comp.setStyle()`) are convenience sugar that construct and dispatch these same ops.
|
||||
</Tip>
|
||||
|
||||
## dispatch, batch, and can
|
||||
|
||||
### dispatch
|
||||
|
||||
```typescript
|
||||
comp.dispatch(op: EditOp, opts?: { origin?: unknown }): void
|
||||
```
|
||||
|
||||
Applies `op` immediately. Emits a `patch` event, persists if an adapter is attached, and records a history entry. The optional `origin` is forwarded verbatim in the resulting `PatchEvent`; use it to label the source of the change (e.g. `"user"`, `"agent"`, or your own string constant).
|
||||
|
||||
```typescript
|
||||
comp.dispatch(
|
||||
{ type: "setStyle", target: "hf-title", styles: { color: "#FFD60A" } },
|
||||
{ origin: "agent" },
|
||||
);
|
||||
```
|
||||
|
||||
### batch
|
||||
|
||||
```typescript
|
||||
comp.batch(fn: () => void, opts?: { origin?: unknown }): void
|
||||
```
|
||||
|
||||
Groups all `dispatch()` calls made inside `fn` into a single undo step, a single persist write, and a single `patch` event. Use `batch()` when several mutations belong together logically.
|
||||
|
||||
```typescript
|
||||
comp.batch(() => {
|
||||
comp.dispatch({ type: "setText", target: "hf-title", value: "Launch Day" });
|
||||
comp.dispatch({ type: "setStyle", target: "hf-title", styles: { fontSize: "96px" } });
|
||||
comp.dispatch({ type: "setTiming", target: "hf-title", start: 0.5, duration: 3 });
|
||||
});
|
||||
```
|
||||
|
||||
### can
|
||||
|
||||
```typescript
|
||||
comp.can(op: EditOp): CanResult
|
||||
```
|
||||
|
||||
Dry-runs `op` without mutating the document. Returns `{ ok: true }` when `dispatch(op)` would succeed, or `{ ok: false; code: string; message: string; hint?: string }` when it would be a no-op or error.
|
||||
|
||||
Use `can()` as a feature-detection gate before rendering controls or applying optional operations:
|
||||
|
||||
```typescript
|
||||
const result = comp.can({
|
||||
type: "setGsapTween",
|
||||
animationId: "anim-1",
|
||||
properties: { ease: "power3.out" },
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
comp.setGsapTween("anim-1", { ease: "power3.out" });
|
||||
} else {
|
||||
console.warn(result.code, result.message);
|
||||
}
|
||||
```
|
||||
|
||||
Stable `code` values for `ok: false`:
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `E_TARGET_NOT_FOUND` | The `target` hf-id does not exist in the document. |
|
||||
| `E_NO_ROOT` | The document has no root element (empty HTML). |
|
||||
| `E_NO_GSAP_TIMELINE` | Op requires a parsed GSAP timeline; parser engine not yet active. |
|
||||
| `E_NO_GSAP_SCRIPT` | Op requires a GSAP `<script>` block; none found in the document. |
|
||||
|
||||
<Note>
|
||||
Several Phase-3b GSAP ops (`addGsapTween`, `setGsapTween`, `removeGsapTween`, `addGsapKeyframe`, `setGsapKeyframe`, `removeGsapKeyframe`, etc.) return `{ ok: false, code: 'E_NO_GSAP_TIMELINE' }` from `can()` until the parser engine ships. `dispatch()` still applies those ops structurally even when `can()` returns false.
|
||||
</Note>
|
||||
|
||||
See also: [`Composition`](/sdk/reference/composition) for the typed-method wrappers, [`Types`](/sdk/reference/types) for `CanResult` and `EditOp`.
|
||||
|
||||
---
|
||||
|
||||
## Element edits
|
||||
|
||||
These ops target one or more elements by explicit hf-id. `target` accepts a single `HfId` string or an `HfId[]` array; when an array is given the op is applied to each id individually within a single batch.
|
||||
|
||||
| `type` | Key fields | What it does |
|
||||
|--------|-----------|--------------|
|
||||
| `setStyle` | `target`, `styles` | Merges CSS inline styles. `null` values remove that property. |
|
||||
| `setText` | `target`, `value` | Replaces the element's direct text content. |
|
||||
| `setAttribute` | `target`, `name`, `value` | Sets or removes an HTML attribute. `null` removes it. Does not touch `style`, `class`, or `data-hf-*`. |
|
||||
| `setTiming` | `target`, `start?`, `duration?`, `trackIndex?` | Updates one or more timing attributes (`data-start`, `data-duration`, `data-track`). Omitted fields are unchanged. |
|
||||
| `setHold` | `target`, `hold` | Sets an elastic hold window; see `ElasticHold` shape below. |
|
||||
| `moveElement` | `target`, `x`, `y` | Repositions the element by setting `data-x` / `data-y` (not CSS `left`/`top`). |
|
||||
| `removeElement` | `target` | Removes the element and all its children from the document. Inverse of `addElement`. |
|
||||
|
||||
### setStyle
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "setStyle",
|
||||
target: "hf-card",
|
||||
styles: {
|
||||
borderRadius: "24px",
|
||||
backgroundColor: "#1A1A1A",
|
||||
color: null, // removes the color property
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`styles` keys are camelCase property names, matching `CSSStyleDeclaration` convention.
|
||||
|
||||
### setText
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "setText",
|
||||
target: ["hf-headline", "hf-sub"],
|
||||
value: "Coming soon",
|
||||
});
|
||||
```
|
||||
|
||||
### setAttribute
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "setAttribute",
|
||||
target: "hf-logo",
|
||||
name: "src",
|
||||
value: "/assets/logo-v2.png",
|
||||
});
|
||||
|
||||
// Remove an attribute:
|
||||
comp.dispatch({
|
||||
type: "setAttribute",
|
||||
target: "hf-video",
|
||||
name: "autoplay",
|
||||
value: null,
|
||||
});
|
||||
```
|
||||
|
||||
### setTiming
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "setTiming",
|
||||
target: "hf-title",
|
||||
start: 1.5,
|
||||
duration: 3,
|
||||
trackIndex: 0,
|
||||
});
|
||||
```
|
||||
|
||||
### setHold
|
||||
|
||||
`hold` is an `ElasticHold` object:
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "setHold",
|
||||
target: "hf-badge",
|
||||
hold: {
|
||||
start: 2,
|
||||
end: 5,
|
||||
fill: "freeze", // "freeze" | "loop"
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### moveElement
|
||||
|
||||
Sets the element's position via `data-x` / `data-y` attributes. Coordinates are in composition-space pixels.
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "moveElement",
|
||||
target: "hf-logo",
|
||||
x: 120,
|
||||
y: 48,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
These ops mutate document structure (add/remove/reorder elements, apply class-level styles, or change composition metadata). They do **not** take a `target` field in the same form as element edits.
|
||||
|
||||
| `type` | Key fields | What it does |
|
||||
|--------|-----------|--------------|
|
||||
| `addElement` | `parent`, `index`, `html` | Inserts an HTML fragment. Returns the minted hf-id via the typed `comp.addElement()` method. |
|
||||
| `reorderElements` | `entries` | Sets inline `z-index` on one or more elements to reorder their stacking. |
|
||||
| `setClassStyle` | `selector`, `styles` | Merges CSS rule styles for a class selector. `null` values remove properties. |
|
||||
| `deleteAllForSelector` | `selector` | Removes all elements matching the CSS selector from the document. |
|
||||
| `setCompositionMetadata` | `width?`, `height?`, `duration?` | Updates top-level composition dimensions and/or total duration. |
|
||||
|
||||
### addElement
|
||||
|
||||
`parent` is the hf-id of the parent element, or `null` to insert at the document body root. `index` is the zero-based sibling index (append if `>= childCount`). `html` must be a single-root HTML fragment and must not contain `<script>`.
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "addElement",
|
||||
parent: "hf-scene-1",
|
||||
index: 2,
|
||||
html: '<div class="clip" data-start="3" data-duration="2">New layer</div>',
|
||||
});
|
||||
```
|
||||
|
||||
Use the typed `comp.addElement(parent, index, html)` method to get back the minted hf-id.
|
||||
|
||||
### reorderElements
|
||||
|
||||
Each entry sets `z-index` on one element. Elements must be non-statically positioned for `z-index` to take effect — the caller must ensure `position` is set.
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "reorderElements",
|
||||
entries: [
|
||||
{ target: "hf-bg", zIndex: 0 },
|
||||
{ target: "hf-logo", zIndex: 10 },
|
||||
{ target: "hf-text", zIndex: 20 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### setClassStyle
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "setClassStyle",
|
||||
selector: ".caption",
|
||||
styles: { fontSize: "14px", fontWeight: "600" },
|
||||
});
|
||||
```
|
||||
|
||||
### deleteAllForSelector
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "deleteAllForSelector",
|
||||
selector: ".debug-overlay",
|
||||
});
|
||||
```
|
||||
|
||||
### setCompositionMetadata
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "setCompositionMetadata",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration: 30,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variables
|
||||
|
||||
| `type` | Key fields | What it does |
|
||||
|--------|-----------|--------------|
|
||||
| `setVariableValue` | `id`, `value` | Sets a composition variable by id. Value may be a string, number, boolean, `FontValue`, or `ImageValue`. |
|
||||
|
||||
```typescript
|
||||
// Scalar variable
|
||||
comp.dispatch({
|
||||
type: "setVariableValue",
|
||||
id: "brandColor",
|
||||
value: "#6C5CE7",
|
||||
});
|
||||
|
||||
// Font variable (object-valued — never a CSS string)
|
||||
comp.dispatch({
|
||||
type: "setVariableValue",
|
||||
id: "brand-font",
|
||||
value: {
|
||||
name: "Inter",
|
||||
source: "https://fonts.googleapis.com/css2?family=Inter:wght@400;700",
|
||||
},
|
||||
});
|
||||
|
||||
// Image variable (object-valued)
|
||||
comp.dispatch({
|
||||
type: "setVariableValue",
|
||||
id: "hero-image",
|
||||
value: {
|
||||
url: "/assets/hero.jpg",
|
||||
alt: "Product hero",
|
||||
fit: "cover",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GSAP tweens
|
||||
|
||||
These ops add, edit, and remove GSAP tween entries in the composition's GSAP script block. They operate by `animationId` — a stable string identifier minted when a tween is created. Use `comp.addGsapTween()` or `addWithKeyframes` to mint a new id; the typed wrapper returns it directly.
|
||||
|
||||
<Note>
|
||||
Several GSAP ops require the parser engine to be active. Until it ships, `can()` returns `{ ok: false, code: 'E_NO_GSAP_TIMELINE' }` for these ops. `dispatch()` still applies the op structurally.
|
||||
</Note>
|
||||
|
||||
| `type` | Key fields | What it does |
|
||||
|--------|-----------|--------------|
|
||||
| `addGsapTween` | `target`, `tween` | Adds a new tween for a target hf-id. Returns the minted `animationId` via the typed method. |
|
||||
| `setGsapTween` | `animationId`, `properties` | Partially updates an existing tween's `GsapTweenSpec` fields. |
|
||||
| `removeGsapTween` | `animationId` | Removes the tween entirely. |
|
||||
| `removeGsapProperty` | `animationId`, `property`, `from?` | Removes one animated property from a tween. `from: true` removes from `fromProperties`; otherwise removes from `toProperties` / `properties`. |
|
||||
|
||||
### addGsapTween
|
||||
|
||||
`tween` is a `GsapTweenSpec` object:
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "addGsapTween",
|
||||
target: "hf-title",
|
||||
tween: {
|
||||
method: "from",
|
||||
position: 0,
|
||||
duration: 0.8,
|
||||
ease: "power3.out",
|
||||
fromProperties: { opacity: 0, y: 40 },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`GsapTweenSpec` fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `method` | `"from" \| "to" \| "fromTo" \| "set"` | GSAP tween method. |
|
||||
| `position` | `number \| string` | Timeline position. Accepts numbers (seconds) or label-relative strings (`"intro+=0.5"`). |
|
||||
| `duration` | `number` | Tween duration in seconds. |
|
||||
| `ease` | `string` | GSAP ease string (e.g. `"power3.out"`). |
|
||||
| `fromProperties` | `Record<string, unknown>` | Start-state properties (used by `from` and `fromTo`). |
|
||||
| `toProperties` | `Record<string, unknown>` | End-state properties (used by `fromTo`). |
|
||||
| `properties` | `Record<string, unknown>` | Animated properties for `to` tweens. |
|
||||
| `repeat` | `number` | Repeat count (`-1` = infinite). |
|
||||
| `yoyo` | `boolean` | Reverse on alternating repeats. |
|
||||
| `stagger` | `number \| Record<string, unknown>` | Stagger config for multi-target tweens. |
|
||||
|
||||
### setGsapTween
|
||||
|
||||
Only the fields you supply are changed; omit any field to leave it unchanged.
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "setGsapTween",
|
||||
animationId: "anim-1",
|
||||
properties: { ease: "elastic.out(1, 0.3)", duration: 1.2 },
|
||||
});
|
||||
```
|
||||
|
||||
### removeGsapProperty
|
||||
|
||||
```typescript
|
||||
// Remove 'scale' from the to-properties of an existing tween
|
||||
comp.dispatch({
|
||||
type: "removeGsapProperty",
|
||||
animationId: "anim-1",
|
||||
property: "scale",
|
||||
});
|
||||
|
||||
// Remove 'opacity' from the from-properties
|
||||
comp.dispatch({
|
||||
type: "removeGsapProperty",
|
||||
animationId: "anim-2",
|
||||
property: "opacity",
|
||||
from: true,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keyframes
|
||||
|
||||
Keyframe ops work with tweens that use CSS `@keyframes`-style percentage arrays rather than a single `fromProperties`/`toProperties` shape.
|
||||
|
||||
| `type` | Key fields | What it does |
|
||||
|--------|-----------|--------------|
|
||||
| `setGsapKeyframe` | `animationId`, `keyframeIndex`, `position?`, `value?`, `ease?` | Updates one keyframe by index inside an existing keyframed tween. |
|
||||
| `addGsapKeyframe` | `animationId`, `position`, `value` | Appends a new keyframe at a given percentage position. |
|
||||
| `removeGsapKeyframe` | `animationId`, `percentage` | Removes the keyframe at a specific percentage. |
|
||||
| `removeAllKeyframes` | `animationId` | Clears all keyframes from a tween, leaving the tween shell intact. |
|
||||
| `convertToKeyframes` | `animationId`, `resolvedFromValues?` | Converts a `from`/`to`/`fromTo` tween into keyframe form. `resolvedFromValues` provides live computed values for the 0% stop. |
|
||||
| `materializeKeyframes` | `animationId`, `keyframes`, `easeEach?`, `resolvedSelector?` | Writes a complete keyframe set to an existing tween, replacing any previous keyframes. |
|
||||
| `addWithKeyframes` | `targetSelector`, `position`, `duration`, `keyframes`, `ease?` | Creates a new keyframed tween for the given CSS selector. Returns minted `animationId` via typed method. |
|
||||
| `replaceWithKeyframes` | `animationId`, `targetSelector`, `position`, `duration`, `keyframes`, `ease?` | Atomically removes an existing tween and inserts a new keyframed tween. |
|
||||
| `splitIntoPropertyGroups` | `animationId` | Splits a multi-property keyframed tween into one tween per animated property. |
|
||||
| `splitAnimations` | `originalId`, `newId`, `splitTime`, `elementStart`, `elementDuration` | Splits one tween into two at `splitTime`. The second half gets `newId`. |
|
||||
| `unrollDynamicAnimations` | `animationId`, `elements` | Converts a selector-targeted tween that matches multiple elements into per-element keyframe tweens. |
|
||||
|
||||
### materializeKeyframes
|
||||
|
||||
`keyframes` is an array of `{ percentage, properties, ease? }` objects:
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "materializeKeyframes",
|
||||
animationId: "anim-3",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { opacity: 0, y: 30 } },
|
||||
{ percentage: 100, properties: { opacity: 1, y: 0 }, ease: "power2.out" },
|
||||
],
|
||||
easeEach: "none",
|
||||
resolvedSelector: "#hf-title",
|
||||
});
|
||||
```
|
||||
|
||||
### addWithKeyframes / replaceWithKeyframes
|
||||
|
||||
`position` is a number (seconds) — unlike `GsapTweenSpec.position`, label-relative strings are not accepted here.
|
||||
|
||||
```typescript
|
||||
// Create a new keyframed tween:
|
||||
comp.dispatch({
|
||||
type: "addWithKeyframes",
|
||||
targetSelector: "#hf-card",
|
||||
position: 1.5,
|
||||
duration: 0.6,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { scale: 0.8, opacity: 0 } },
|
||||
{ percentage: 100, properties: { scale: 1, opacity: 1 } },
|
||||
],
|
||||
ease: "back.out(1.7)",
|
||||
});
|
||||
|
||||
// Replace an existing tween atomically:
|
||||
comp.dispatch({
|
||||
type: "replaceWithKeyframes",
|
||||
animationId: "anim-4",
|
||||
targetSelector: "#hf-card",
|
||||
position: 1.5,
|
||||
duration: 0.6,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { scale: 0.9 } },
|
||||
{ percentage: 100, properties: { scale: 1 } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
After `replaceWithKeyframes`, position-derived tween IDs renumber. Re-query `comp.getElement(id).animationIds` to discover the new ID rather than assuming it matches the old one.
|
||||
</Note>
|
||||
|
||||
`KeyframeSpec` fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `percentage` | `number` | Keyframe stop position (0–100). |
|
||||
| `properties` | `Record<string, number \| string>` | CSS / GSAP properties at this stop. |
|
||||
| `ease` | `string` | Ease applied from this stop to the next. |
|
||||
| `auto` | `boolean` | GSAP endpoint flag — emitted as numeric `_auto: 1`. |
|
||||
|
||||
---
|
||||
|
||||
## Labels
|
||||
|
||||
GSAP timeline labels mark named positions (in seconds) in the master timeline. Labels are referenced in `GsapTweenSpec.position` as strings like `"intro"` or `"intro+=0.5"`.
|
||||
|
||||
| `type` | Key fields | What it does |
|
||||
|--------|-----------|--------------|
|
||||
| `addLabel` | `name`, `position` | Adds a named label at a timeline position (seconds). |
|
||||
| `removeLabel` | `name` | Removes a named label. |
|
||||
|
||||
```typescript
|
||||
comp.dispatch({ type: "addLabel", name: "intro", position: 0 });
|
||||
comp.dispatch({ type: "addLabel", name: "outro", position: 8 });
|
||||
|
||||
// Tween positioned relative to a label:
|
||||
comp.dispatch({
|
||||
type: "addGsapTween",
|
||||
target: "hf-cta",
|
||||
tween: {
|
||||
method: "from",
|
||||
position: "outro-=0.5",
|
||||
duration: 0.4,
|
||||
fromProperties: { opacity: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
comp.dispatch({ type: "removeLabel", name: "intro" });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Arc paths
|
||||
|
||||
Arc path ops control the motion path of a GSAP tween — the curve along which an element travels.
|
||||
|
||||
| `type` | Key fields | What it does |
|
||||
|--------|-----------|--------------|
|
||||
| `setArcPath` | `animationId`, `config` | Creates or replaces the arc-path config on a tween. |
|
||||
| `updateArcSegment` | `animationId`, `segmentIndex`, `update` | Updates one segment's curviness or control points. |
|
||||
| `removeArcPath` | `animationId` | Removes the arc-path from a tween, reverting to a straight-line path. |
|
||||
|
||||
### setArcPath
|
||||
|
||||
`config` shape:
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "setArcPath",
|
||||
animationId: "anim-5",
|
||||
config: {
|
||||
enabled: true,
|
||||
autoRotate: true, // boolean, or a number (degrees offset)
|
||||
segments: [
|
||||
{
|
||||
curviness: 1.5,
|
||||
cp1: { x: 200, y: -80 }, // first control point
|
||||
cp2: { x: 400, y: 20 }, // second control point
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `enabled` | `boolean` | Whether the arc path is active. |
|
||||
| `autoRotate` | `boolean \| number` | `true` = auto-rotate to follow path tangent; a number offsets the rotation by that many degrees. |
|
||||
| `segments` | `Array<{ curviness?, cp1?, cp2? }>` | Per-segment path definition. `curviness` is a GSAP Bezier curviness value; `cp1`/`cp2` are control-point coordinates in composition space. |
|
||||
|
||||
### updateArcSegment
|
||||
|
||||
```typescript
|
||||
comp.dispatch({
|
||||
type: "updateArcSegment",
|
||||
animationId: "anim-5",
|
||||
segmentIndex: 0,
|
||||
update: { curviness: 2, cp1: { x: 220, y: -100 } },
|
||||
});
|
||||
```
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: "openComposition"
|
||||
description: "Open a composition HTML string for editing. Returns a Composition session."
|
||||
---
|
||||
|
||||
`openComposition` is the single entry point for every SDK session. It parses the composition HTML, stamps stable `hf-id` attributes on any elements that lack them, and returns a [`Composition`](/sdk/reference/composition) ready to receive edits.
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
|
||||
const comp = await openComposition(html, opts?);
|
||||
```
|
||||
|
||||
## Signature
|
||||
|
||||
```typescript
|
||||
openComposition(html: string, opts?: OpenCompositionOptions): Promise<Composition>
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `html` | `string` | The full composition HTML string. |
|
||||
| `opts` | `OpenCompositionOptions` | Optional configuration — adapters, overrides, history behavior. |
|
||||
|
||||
## Modes
|
||||
|
||||
The same function covers three deployment patterns. Which mode you get depends entirely on what you pass in `opts`.
|
||||
|
||||
**Standalone (default)** — You own the HTML, and the SDK owns history and persistence. Pass a `persist` adapter to enable autosave; undo/redo attaches automatically unless you opt out with `history: false`.
|
||||
|
||||
**Embedded / override mode** — Pass `overrides` to open a shared base template with a user-specific delta applied on top. The SDK accumulates further edits into the override set, and you store only the delta. History is disabled by default in embedded mode (the host's stack drives undo). See the [embedded override mode guide](/sdk/guides/embedded-override-mode).
|
||||
|
||||
**Headless / agent** — Omit both `persist` and `overrides`. The SDK is a pure in-memory transform. Call `serialize()` when done and discard the session.
|
||||
|
||||
## Options
|
||||
|
||||
<ParamField path="persist" type="PersistAdapter">
|
||||
Storage adapter for autosave. After every committed change the SDK enqueues a write to this adapter. The session fires `persist:error` events instead of throwing on write failures.
|
||||
|
||||
Built-in adapters: `createMemoryAdapter()` (tests/demos) and `createFsAdapter()` (Node.js). Custom adapters implement the `PersistAdapter` interface, exported from `@hyperframes/sdk`. (`createHeadlessAdapter()` is a `PreviewAdapter` for the `preview` field below — not a persist adapter.)
|
||||
|
||||
See the [persistence guide](/sdk/guides/persistence) for adapter selection and configuration.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="persistPath" type="string" default='"composition.html"'>
|
||||
Relative path the persist queue passes to the adapter's `write` call. Immutable for the lifetime of the session — changing it would create a new file on disk without removing the old one. You must close and reopen the session to change the save path.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="preview" type="PreviewAdapter">
|
||||
Live-preview adapter that bridges the SDK model to a rendering surface. When provided, the adapter's selection events update `comp.getSelection()` so pointer-driven hit tests stay in sync with the model without crossing the mutation path.
|
||||
|
||||
Built-in: `createIframePreviewAdapter()` for same-origin composition iframes, `createHeadlessAdapter()` when no preview surface exists.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="overrides" type="OverrideSet">
|
||||
Sparse `{ "hfId.prop.path": value }` map that is applied on top of the base HTML before the session starts. Every subsequent edit accumulates into this map. The current map is readable via `comp.getOverrides()`.
|
||||
|
||||
Providing `overrides` activates **embedded mode**: history defaults to off, and the SDK emits patches rather than owning the undo stack. The host stores only the delta and replays it on each open, so the base template can be updated independently.
|
||||
|
||||
See the [embedded override mode guide](/sdk/guides/embedded-override-mode).
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="trackedOrigins" type="unknown[]">
|
||||
Whitelist of origin values whose mutations enter the undo stack. Defaults to all origins except `ORIGIN_APPLY_PATCHES` (the sentinel used by `applyPatches()`). Use this when you dispatch edits from multiple sources and only want some of them to be undoable — for example, to exclude programmatic bulk updates from user-facing undo history.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="coalesceMs" type="number" default={300}>
|
||||
Auto-coalesce window in milliseconds. Consecutive mutations arriving within this window are merged into a single undo entry. Increase for slower interactions, decrease for tighter history granularity, or set to `0` to disable coalescing entirely.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="history" type="false">
|
||||
Pass `false` to skip attaching the undo/redo module. The session's `undo()`, `redo()`, `canUndo()`, and `canRedo()` become no-ops.
|
||||
|
||||
Use this when the host owns the undo stack and the SDK's history module would duplicate it. Note that disabling history does **not** disable persistence — autosave runs independently so that disabling undo does not silently drop disk writes.
|
||||
|
||||
In embedded mode (when `overrides` is provided), history defaults to off and this option is unnecessary.
|
||||
</ParamField>
|
||||
|
||||
## Examples
|
||||
|
||||
### Headless agent edit
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
|
||||
const comp = await openComposition(html);
|
||||
|
||||
const [titleId] = comp.find({ name: "headline" });
|
||||
if (titleId) {
|
||||
comp.setText(titleId, "New headline");
|
||||
comp.setStyle(titleId, { color: "#FFD60A" });
|
||||
}
|
||||
|
||||
const updatedHtml = comp.serialize();
|
||||
comp.dispose();
|
||||
```
|
||||
|
||||
### Standalone with filesystem persistence
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
|
||||
|
||||
const comp = await openComposition(html, {
|
||||
persist: createFsAdapter({ root: "./project" }),
|
||||
persistPath: "index.html",
|
||||
});
|
||||
|
||||
comp.on("persist:error", ({ error }) => {
|
||||
console.error("Autosave failed:", error.message);
|
||||
});
|
||||
|
||||
comp.setText("hf-title", "Launch day");
|
||||
await comp.flush(); // drain any pending write before process exit
|
||||
comp.dispose();
|
||||
```
|
||||
|
||||
### Embedded mode with overrides
|
||||
|
||||
```typescript
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
|
||||
// Stored override delta for one customer
|
||||
const savedOverrides = {
|
||||
"hf-title.text": "Acme — Q3 Review",
|
||||
"hf-logo.attr.src": "/customers/acme/logo.png",
|
||||
};
|
||||
|
||||
const comp = await openComposition(baseTemplateHtml, {
|
||||
overrides: savedOverrides,
|
||||
history: false,
|
||||
});
|
||||
|
||||
// Host drives undo; SDK just accumulates overrides
|
||||
comp.on("patch", ({ patches, inversePatches, origin }) => {
|
||||
if (origin !== ORIGIN_APPLY_PATCHES) {
|
||||
hostHistoryStack.push({ patches, inversePatches });
|
||||
}
|
||||
});
|
||||
|
||||
comp.setStyle("hf-title", { color: "#0EA5E9" });
|
||||
|
||||
// Persist the updated delta only — not the full HTML
|
||||
const nextOverrides = comp.getOverrides();
|
||||
await db.save(customerId, nextOverrides);
|
||||
comp.dispose();
|
||||
```
|
||||
|
||||
### Disabling history to reduce overhead
|
||||
|
||||
```typescript
|
||||
const comp = await openComposition(html, {
|
||||
persist: createFsAdapter({ root: "./output" }),
|
||||
history: false, // host owns undo; persist still runs
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
History defaults to **on** in standalone mode and **off** in embedded mode. Passing `history: false` in standalone mode is only necessary when you are managing the undo stack yourself and do not want the SDK to create a parallel copy.
|
||||
</Note>
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Composition" icon="cube" href="/sdk/reference/composition">
|
||||
The full editing surface returned by openComposition.
|
||||
</Card>
|
||||
<Card title="Persistence guide" icon="floppy-disk" href="/sdk/guides/persistence">
|
||||
Adapter selection, version history, and custom adapters.
|
||||
</Card>
|
||||
<Card title="Embedded override mode" icon="layers" href="/sdk/guides/embedded-override-mode">
|
||||
Template-driven products with host-owned history.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
---
|
||||
title: "Types"
|
||||
description: "All exported types from @hyperframes/sdk."
|
||||
---
|
||||
|
||||
This page documents every type exported from `@hyperframes/sdk`. Types are verified against `packages/sdk/src/types.ts` and the export barrel at `packages/sdk/src/index.ts`.
|
||||
|
||||
See also: [`Composition`](/sdk/reference/composition) for the main session interface, [`Edit Operations`](/sdk/reference/edit-operations) for the `EditOp` catalog.
|
||||
|
||||
---
|
||||
|
||||
## HfId
|
||||
|
||||
```typescript
|
||||
type HfId = string;
|
||||
```
|
||||
|
||||
A stable HyperFrames element identifier — the value of the `data-hf-id` attribute stamped on every editable element by `ensureHfIds`. Use `HfId` wherever you would write a raw `string` to signal that the value is an element id.
|
||||
|
||||
---
|
||||
|
||||
## HyperFramesElement
|
||||
|
||||
Full DOM-level view of one editable element. Returned by `comp.getElements()` and `comp.getElement(id)`.
|
||||
|
||||
```typescript
|
||||
interface HyperFramesElement {
|
||||
readonly id: string;
|
||||
readonly scopedId: string;
|
||||
readonly tag: string;
|
||||
readonly children: readonly HyperFramesElement[];
|
||||
readonly inlineStyles: Readonly<Record<string, string>>;
|
||||
readonly classNames: readonly string[];
|
||||
readonly attributes: Readonly<Record<string, string>>;
|
||||
readonly text: string | null;
|
||||
readonly start: number | null;
|
||||
readonly duration: number | null;
|
||||
readonly trackIndex: number | null;
|
||||
readonly animationIds: readonly string[];
|
||||
}
|
||||
```
|
||||
|
||||
<ResponseField name="id" type="string">
|
||||
The element's `data-hf-id` value — unique within the top-level document.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="scopedId" type="string">
|
||||
Fully-qualified scoped identifier: `host-chain-prefix/leaf`, separated by `/`. For top-level elements `scopedId === id`. For elements inside inlined sub-compositions the format is `"hf-HOST/hf-LEAF"` (any depth). Use `scopedId` — not `id` — as the canonical key in `dispatch()` targets, `getElement()`, `find()`, and `OverrideSet` keys when addressing sub-composition elements.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="tag" type="string">
|
||||
Lowercase HTML tag name (`"div"`, `"img"`, `"p"`, …).
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="children" type="readonly HyperFramesElement[]">
|
||||
Direct child elements. Build a flat list with `flatElements()` from `@hyperframes/sdk` utilities.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="inlineStyles" type="Readonly<Record<string, string>>">
|
||||
Inline CSS properties in camelCase, mirroring `CSSStyleDeclaration` convention (`fontSize`, not `font-size`).
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="classNames" type="readonly string[]">
|
||||
The element's class list.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="attributes" type="Readonly<Record<string, string>>">
|
||||
All HTML attributes except `style`, `class`, and `data-hf-*` (those are represented elsewhere in the model).
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="text" type="string | null">
|
||||
The element's direct text content, intended as the `setText` target. Not a full descendant-text snapshot.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="start" type="number | null">
|
||||
Timing start in seconds (`data-start`). `null` when the element carries no timing attribute.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="duration" type="number | null">
|
||||
Timing duration in seconds (`data-duration`). `null` when not set.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="trackIndex" type="number | null">
|
||||
Zero-based track index (`data-track`). `null` when not set.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="animationIds" type="readonly string[]">
|
||||
IDs of GSAP tweens whose target is this element. Pass these to the GSAP tween ops in `dispatch()`.
|
||||
</ResponseField>
|
||||
|
||||
---
|
||||
|
||||
## ElementSnapshot
|
||||
|
||||
```typescript
|
||||
type ElementSnapshot = HyperFramesElement;
|
||||
```
|
||||
|
||||
Type alias for `HyperFramesElement`. Used in return types of `getElements()` and `getElement()` to signal that the value is a read-only snapshot — it does not update after subsequent mutations.
|
||||
|
||||
---
|
||||
|
||||
## SdkDocument
|
||||
|
||||
The SDK's in-memory document model. Returned by `buildDocument()`.
|
||||
|
||||
```typescript
|
||||
interface SdkDocument {
|
||||
readonly roots: readonly HyperFramesElement[];
|
||||
readonly gsapScript: string | null;
|
||||
readonly styles: string | null;
|
||||
readonly width: number | null;
|
||||
readonly height: number | null;
|
||||
readonly compositionDuration: number | null;
|
||||
readonly html: string;
|
||||
}
|
||||
```
|
||||
|
||||
<ResponseField name="roots" type="readonly HyperFramesElement[]">
|
||||
Top-level elements of the composition body. Walk the tree via `.children`.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="gsapScript" type="string | null">
|
||||
Raw contents of the composition's GSAP `<script>` block, if present.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="styles" type="string | null">
|
||||
Raw contents of the composition's `<style>` block, if present.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="width" type="number | null">
|
||||
Composition width in pixels from `data-width` on the root element. `null` if not set.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="height" type="number | null">
|
||||
Composition height in pixels from `data-height`. `null` if not set.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="compositionDuration" type="number | null">
|
||||
Total duration in seconds from `data-duration` on the root element. `null` if not set.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="html" type="string">
|
||||
Build-time snapshot of the `ensureHfIds`-stamped HTML. This value is **never updated** after mutations — call `comp.serialize()` to get the current document state.
|
||||
</ResponseField>
|
||||
|
||||
---
|
||||
|
||||
## OverrideSet
|
||||
|
||||
```typescript
|
||||
type OverrideSet = Record<
|
||||
string,
|
||||
string | number | boolean | Record<string, unknown> | null
|
||||
>;
|
||||
```
|
||||
|
||||
A sparse map of overrides layered on top of a base composition template. Used in embedded override mode (T3) — pass as `overrides` to `openComposition()`, or read the accumulated delta with `comp.getOverrides()`.
|
||||
|
||||
**Key format:** `hfId.prop.path`, where:
|
||||
|
||||
- `hfId.style.fontSize` — inline style property override
|
||||
- `hfId.text` — text content override
|
||||
- `hfId.attr.src` — attribute override
|
||||
- `hfId` (key only, value `null`) — element removed by user
|
||||
- `var.{id}` — variable override; value is a scalar or an object (`FontValue` / `ImageValue`)
|
||||
|
||||
**`null` value** = removal marker: the element or property was deleted by the user.
|
||||
|
||||
```typescript
|
||||
const overrides: OverrideSet = {
|
||||
"hf-title.text": "Customer headline",
|
||||
"hf-logo.attr.src": "/customers/acme/logo.png",
|
||||
"hf-title.style.color": "#0EA5E9",
|
||||
"hf-old-badge": null, // element removed
|
||||
"var.brand-font": { // font variable
|
||||
name: "Inter",
|
||||
source: "https://fonts.googleapis.com/css2?family=Inter",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
<Warning>
|
||||
The value union includes `Record<string, unknown>` to admit object-valued variables (font, image). Code that reads an `OverrideSet` value must narrow before assuming a scalar — an object value type-checks anywhere `unknown` is accepted.
|
||||
</Warning>
|
||||
|
||||
---
|
||||
|
||||
## FindQuery
|
||||
|
||||
Passed to `comp.find(query)` to search elements by descriptor.
|
||||
|
||||
```typescript
|
||||
interface FindQuery {
|
||||
tag?: string;
|
||||
text?: string;
|
||||
name?: string;
|
||||
track?: number;
|
||||
composition?: string;
|
||||
}
|
||||
```
|
||||
|
||||
<ResponseField name="tag" type="string">
|
||||
Match by lowercase HTML tag name (`"img"`, `"p"`, …).
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="text" type="string">
|
||||
Match by the element's direct text content (case-sensitive substring match — implemented with `String.includes`).
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="name" type="string">
|
||||
Match by `data-name` attribute value.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="track" type="number">
|
||||
Match by zero-based track index (`data-track`).
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="composition" type="string">
|
||||
Restrict results to elements inside a specific sub-composition, identified by the host element's hf-id.
|
||||
</ResponseField>
|
||||
|
||||
All fields are optional; non-null fields are ANDed.
|
||||
|
||||
```typescript
|
||||
// Find all text elements on track 2:
|
||||
const ids = comp.find({ tag: "p", track: 2 });
|
||||
|
||||
// Find inside a sub-composition:
|
||||
const subIds = comp.find({ composition: "hf-scene-2", tag: "img" });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GsapTweenSpec
|
||||
|
||||
Describes a single GSAP tween entry to create or update.
|
||||
|
||||
```typescript
|
||||
interface GsapTweenSpec {
|
||||
method: "from" | "to" | "fromTo" | "set";
|
||||
position?: number | string;
|
||||
duration?: number;
|
||||
ease?: string;
|
||||
fromProperties?: Record<string, unknown>;
|
||||
toProperties?: Record<string, unknown>;
|
||||
properties?: Record<string, unknown>;
|
||||
repeat?: number;
|
||||
yoyo?: boolean;
|
||||
stagger?: number | Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
<ResponseField name="method" type='"from" | "to" | "fromTo" | "set"'>
|
||||
GSAP tween method. `"from"` animates from the given values to the computed values; `"to"` animates toward the given values; `"fromTo"` animates from `fromProperties` to `toProperties`; `"set"` snaps without interpolation.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="position" type="number | string">
|
||||
Timeline insertion position. A number is seconds from the timeline start. A string is a label-relative offset such as `"intro"` or `"outro-=0.5"`. Omit to append.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="duration" type="number">
|
||||
Tween duration in seconds.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="ease" type="string">
|
||||
GSAP ease string, e.g. `"power3.out"`, `"elastic.out(1, 0.3)"`, `"none"`.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="fromProperties" type="Record<string, unknown>">
|
||||
Start-state property map — used by `"from"` and `"fromTo"` methods.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="toProperties" type="Record<string, unknown>">
|
||||
End-state property map — used by `"fromTo"` method.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="properties" type="Record<string, unknown>">
|
||||
Animated property map — used by `"to"` method.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="repeat" type="number">
|
||||
How many times to repeat. `-1` = infinite.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="yoyo" type="boolean">
|
||||
Reverse direction on alternating repeats.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="stagger" type="number | Record<string, unknown>">
|
||||
GSAP stagger config for multi-target tweens.
|
||||
</ResponseField>
|
||||
|
||||
---
|
||||
|
||||
## KeyframeSpec
|
||||
|
||||
A single keyframe entry for `addWithKeyframes` and `replaceWithKeyframes`. This is a structural shape, not a runtime export — you pass plain objects of this form to those methods; it is **not** re-exported from the `@hyperframes/sdk` barrel, so there is nothing to import.
|
||||
|
||||
```typescript
|
||||
interface KeyframeSpec {
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
auto?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
<ResponseField name="percentage" type="number">
|
||||
Keyframe stop position within the tween, from 0 to 100.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="properties" type="Record<string, number | string>">
|
||||
CSS and GSAP property values at this keyframe stop.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="ease" type="string">
|
||||
Ease applied from this stop to the next.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="auto" type="boolean">
|
||||
GSAP endpoint flag. When `true`, emitted as numeric `_auto: 1` in the generated GSAP script.
|
||||
</ResponseField>
|
||||
|
||||
---
|
||||
|
||||
## ElasticHold
|
||||
|
||||
Controls the elastic hold (freeze or loop) window for an element.
|
||||
|
||||
```typescript
|
||||
interface ElasticHold {
|
||||
start: number;
|
||||
end: number;
|
||||
fill: "freeze" | "loop";
|
||||
}
|
||||
```
|
||||
|
||||
<ResponseField name="start" type="number">
|
||||
Start of the hold window in seconds.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="end" type="number">
|
||||
End of the hold window in seconds.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="fill" type='"freeze" | "loop"'>
|
||||
`"freeze"` holds the last frame statically; `"loop"` cycles the animation within the window.
|
||||
</ResponseField>
|
||||
|
||||
---
|
||||
|
||||
## FontValue
|
||||
|
||||
Object value for a `font` composition variable. Always an object — never a raw CSS string.
|
||||
|
||||
```typescript
|
||||
interface FontValue {
|
||||
name: string;
|
||||
source: string;
|
||||
}
|
||||
```
|
||||
|
||||
<ResponseField name="name" type="string">
|
||||
CSS `font-family` value, e.g. `"Inter"` or `"'Playfair Display'"`.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="source" type="string">
|
||||
Stylesheet URL to load for this font, e.g. a Google Fonts CSS URL.
|
||||
</ResponseField>
|
||||
|
||||
---
|
||||
|
||||
## ImageValue
|
||||
|
||||
Object value for an `image` composition variable. Always an object — never a raw URL string.
|
||||
|
||||
```typescript
|
||||
interface ImageValue {
|
||||
url: string;
|
||||
alt?: string;
|
||||
fit?: "cover" | "contain" | "fill" | "none" | "scale-down";
|
||||
}
|
||||
```
|
||||
|
||||
<ResponseField name="url" type="string">
|
||||
Image source URL.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="alt" type="string">
|
||||
Alternative text for accessibility.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="fit" type='"cover" | "contain" | "fill" | "none" | "scale-down"'>
|
||||
Object-fit behavior for the image within its container. Maps to the CSS `object-fit` property.
|
||||
</ResponseField>
|
||||
|
||||
---
|
||||
|
||||
## JsonPatchOp
|
||||
|
||||
An emit-only subset of [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) JSON Patch. The SDK never emits `move`, `copy`, or `test` ops, and `applyPatches()` ignores operations outside this subset.
|
||||
|
||||
```typescript
|
||||
interface JsonPatchOp {
|
||||
op: "add" | "remove" | "replace";
|
||||
path: string;
|
||||
value?: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
<ResponseField name="op" type='"add" | "remove" | "replace"'>
|
||||
The patch operation type.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="path" type="string">
|
||||
RFC 6902 JSON Pointer path to the document location being changed.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="value" type="unknown">
|
||||
New value for `"add"` and `"replace"` ops. Omitted for `"remove"`.
|
||||
</ResponseField>
|
||||
|
||||
<Warning>
|
||||
Hosts feeding patches back into `applyPatches()` must restrict themselves to `add`, `remove`, and `replace`. Other RFC 6902 op types are silently ignored.
|
||||
</Warning>
|
||||
|
||||
---
|
||||
|
||||
## PatchEvent
|
||||
|
||||
Emitted by `comp.on('patch', handler)` after every committed change (single `dispatch()` or completed `batch()`).
|
||||
|
||||
```typescript
|
||||
interface PatchEvent {
|
||||
readonly formatVersion: 1;
|
||||
readonly patches: readonly JsonPatchOp[];
|
||||
readonly inversePatches: readonly JsonPatchOp[];
|
||||
readonly origin: unknown;
|
||||
readonly opTypes: readonly string[];
|
||||
}
|
||||
```
|
||||
|
||||
<ResponseField name="formatVersion" type="1">
|
||||
Always `1`. A bump in `formatVersion` is a breaking change. Hosts should check this value once on startup and reject unknown versions.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="patches" type="readonly JsonPatchOp[]">
|
||||
Forward patches that transform the previous document state into the new state.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="inversePatches" type="readonly JsonPatchOp[]">
|
||||
Inverse patches that undo this change — apply to the new state to return to the previous state. Store these for host-side undo stacks.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="origin" type="unknown">
|
||||
Re-emitted verbatim from the mutation entry. Identify automated patches by checking `origin === ORIGIN_APPLY_PATCHES`.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="opTypes" type="readonly string[]">
|
||||
Semantic op names from the `EditOp.type` field (e.g. `["setStyle", "setText"]`). Intended for analytics and history labels. **Not versioned** — do not use in branching logic.
|
||||
</ResponseField>
|
||||
|
||||
**Loop prevention.** When a host mirrors patch events into the SDK via `applyPatches()`, it must skip events that originated from that call to avoid infinite loops:
|
||||
|
||||
```typescript
|
||||
comp.on("patch", ({ patches, inversePatches, origin }) => {
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return;
|
||||
saveToHostHistory({ patches, inversePatches });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CanResult
|
||||
|
||||
Returned by `comp.can(op)`. See [`Edit Operations`](/sdk/reference/edit-operations) for the full usage pattern.
|
||||
|
||||
```typescript
|
||||
type CanResult =
|
||||
| { ok: true }
|
||||
| { ok: false; code: string; message: string; hint?: string };
|
||||
```
|
||||
|
||||
<ResponseField name="ok" type="boolean">
|
||||
`true` when `dispatch(op)` would succeed. `false` when it would be a no-op or error.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="code" type="string">
|
||||
Stable error code for `ok: false` — safe to use in `switch` statements. See the code table in [Edit Operations](/sdk/reference/edit-operations#dispatch-batch-and-can).
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="message" type="string">
|
||||
Human-readable explanation of why the op cannot proceed.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="hint" type="string">
|
||||
Optional actionable suggestion to resolve the issue.
|
||||
</ResponseField>
|
||||
|
||||
---
|
||||
|
||||
## ElementTimingSnapshot
|
||||
|
||||
Resolved timing for one element, returned as values in the `Record<HfId, ElementTimingSnapshot>` from `comp.getElementTimings()`.
|
||||
|
||||
```typescript
|
||||
interface ElementTimingSnapshot {
|
||||
enterAt: number;
|
||||
exitAt: number;
|
||||
labels: string[];
|
||||
}
|
||||
```
|
||||
|
||||
<ResponseField name="enterAt" type="number">
|
||||
Time in seconds when the element enters. Derived from `data-duration` (preferred) or `data-end − data-start` as fallback, using the same logic as `setTiming`.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="exitAt" type="number">
|
||||
Time in seconds when the element exits.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="labels" type="string[]">
|
||||
GSAP `addLabel` names whose numeric position falls within `[enterAt, exitAt]` for this element. Parsed fresh from the GSAP script on every `getElementTimings()` call — never cached.
|
||||
</ResponseField>
|
||||
|
||||
---
|
||||
|
||||
## SelectionProxy
|
||||
|
||||
Proxy returned by `comp.selection()`. Resolves the current selection at call time and applies ops per-id within a single batch. Selection changes between calls are picked up automatically since `ids` is resolved fresh each time you call `comp.selection()`.
|
||||
|
||||
```typescript
|
||||
interface SelectionProxy {
|
||||
readonly ids: readonly string[];
|
||||
setStyle(styles: Record<string, string | null>): void;
|
||||
setText(value: string): void;
|
||||
setAttribute(name: string, value: string | null): void;
|
||||
setTiming(timing: { start?: number; duration?: number; trackIndex?: number }): void;
|
||||
removeElement(): void;
|
||||
}
|
||||
```
|
||||
|
||||
<ResponseField name="ids" type="readonly string[]">
|
||||
The hf-ids in the current selection at the moment `comp.selection()` was called.
|
||||
</ResponseField>
|
||||
|
||||
```typescript
|
||||
const sel = comp.selection();
|
||||
if (sel.ids.length > 0) {
|
||||
sel.setStyle({ opacity: "0.5" });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ElementHandle
|
||||
|
||||
A curried element handle returned by `comp.element(id)`. Holds only the id string — no stale-reference hazard — and provides the same mutation methods as typed methods on `Composition`.
|
||||
|
||||
```typescript
|
||||
interface ElementHandle {
|
||||
readonly id: string;
|
||||
setStyle(styles: Record<string, string | null>): void;
|
||||
setText(value: string): void;
|
||||
setAttribute(name: string, value: string | null): void;
|
||||
setTiming(timing: { start?: number; duration?: number; trackIndex?: number }): void;
|
||||
removeElement(): void;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
const title = comp.element("hf-title");
|
||||
title.setText("Launch Day");
|
||||
title.setStyle({ color: "#FFD60A", fontSize: "96px" });
|
||||
title.setTiming({ start: 0.5, duration: 3 });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ORIGIN_APPLY_PATCHES / ORIGIN_LOCAL
|
||||
|
||||
```typescript
|
||||
const ORIGIN_APPLY_PATCHES = "@hyperframes/sdk:applyPatches";
|
||||
const ORIGIN_LOCAL = "local";
|
||||
```
|
||||
|
||||
Reserved `origin` tag constants:
|
||||
|
||||
- **`ORIGIN_APPLY_PATCHES`** — automatically set by `comp.applyPatches()`. Listeners **must** skip this origin to prevent undo loops when mirroring patch events back into the SDK. Uses a namespaced string (not a unique Symbol) so the sentinel survives realm boundaries such as `postMessage` and structured clone, which T3 embedded hosts may use to forward patch events.
|
||||
- **`ORIGIN_LOCAL`** — default origin used when `dispatch()` or `batch()` is called without an explicit `origin` option. Signals UI-driven user edits.
|
||||
|
||||
```typescript
|
||||
comp.on("patch", ({ origin }) => {
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return; // skip — would loop
|
||||
if (origin === ORIGIN_LOCAL) {
|
||||
// user-driven edit — sync to collaboration layer
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## EditOp
|
||||
|
||||
The full union of all dispatchable edit operations. See [Edit Operations](/sdk/reference/edit-operations) for the complete catalog with field descriptions and code examples.
|
||||
|
||||
```typescript
|
||||
type EditOp =
|
||||
| { type: "setStyle"; target: HfId | HfId[]; styles: Record<string, string | null> }
|
||||
| { type: "setText"; target: HfId | HfId[]; value: string }
|
||||
| { type: "setAttribute"; target: HfId | HfId[]; name: string; value: string | null }
|
||||
| { type: "setTiming"; target: HfId | HfId[]; start?: number; duration?: number; trackIndex?: number }
|
||||
| { type: "setHold"; target: HfId | HfId[]; hold: ElasticHold }
|
||||
| { type: "moveElement"; target: HfId | HfId[]; x: number; y: number }
|
||||
| { type: "removeElement"; target: HfId | HfId[] }
|
||||
| { type: "addElement"; parent: HfId | null; index: number; html: string }
|
||||
| { type: "reorderElements"; entries: Array<{ target: HfId; zIndex: number }> }
|
||||
| { type: "setClassStyle"; selector: string; styles: Record<string, string | null> }
|
||||
| { type: "setCompositionMetadata"; width?: number; height?: number; duration?: number }
|
||||
| { type: "setVariableValue"; id: string; value: string | number | boolean | FontValue | ImageValue }
|
||||
| { type: "addGsapTween"; target: HfId; tween: GsapTweenSpec }
|
||||
| { type: "setGsapTween"; animationId: string; properties: Partial<GsapTweenSpec> }
|
||||
| { type: "removeGsapTween"; animationId: string }
|
||||
| { type: "removeGsapProperty"; animationId: string; property: string; from?: boolean }
|
||||
| { type: "setGsapKeyframe"; animationId: string; keyframeIndex: number; position?: number; value?: Record<string, unknown>; ease?: string }
|
||||
| { type: "addGsapKeyframe"; animationId: string; position: number; value: Record<string, unknown> }
|
||||
| { type: "removeGsapKeyframe"; animationId: string; percentage: number }
|
||||
| { type: "removeAllKeyframes"; animationId: string }
|
||||
| { type: "convertToKeyframes"; animationId: string; resolvedFromValues?: Record<string, number | string> }
|
||||
| { type: "materializeKeyframes"; animationId: string; keyframes: Array<{ percentage: number; properties: Record<string, number | string>; ease?: string }>; easeEach?: string; resolvedSelector?: string }
|
||||
| { type: "splitIntoPropertyGroups"; animationId: string }
|
||||
| { type: "splitAnimations"; originalId: string; newId: string; splitTime: number; elementStart: number; elementDuration: number }
|
||||
| { type: "addLabel"; name: string; position: number }
|
||||
| { type: "removeLabel"; name: string }
|
||||
| { type: "setArcPath"; animationId: string; config: { enabled: boolean; autoRotate: boolean | number; segments: Array<{ curviness?: number; cp1?: { x: number; y: number }; cp2?: { x: number; y: number } }> } }
|
||||
| { type: "updateArcSegment"; animationId: string; segmentIndex: number; update: { curviness?: number; cp1?: { x: number; y: number }; cp2?: { x: number; y: number } } }
|
||||
| { type: "removeArcPath"; animationId: string }
|
||||
| { type: "unrollDynamicAnimations"; animationId: string; elements: Array<{ selector: string; keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>; easeEach?: string }> }
|
||||
| { type: "addWithKeyframes"; targetSelector: string; position: number; duration: number; keyframes: KeyframeSpec[]; ease?: string }
|
||||
| { type: "replaceWithKeyframes"; animationId: string; targetSelector: string; position: number; duration: number; keyframes: KeyframeSpec[]; ease?: string }
|
||||
| { type: "deleteAllForSelector"; selector: string };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PersistErrorEvent
|
||||
|
||||
Emitted by `comp.on('persist:error', handler)` when an autosave write fails. Failures are non-fatal — the session continues operating normally.
|
||||
|
||||
```typescript
|
||||
interface PersistErrorEvent {
|
||||
error: { message: string; hint?: string; cause?: unknown };
|
||||
}
|
||||
```
|
||||
|
||||
<ResponseField name="error.message" type="string">
|
||||
Human-readable description of the persist failure.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="error.hint" type="string">
|
||||
Optional suggestion for resolving the failure.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="error.cause" type="unknown">
|
||||
Underlying error object, if available.
|
||||
</ResponseField>
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
---
|
||||
title: "Utilities & Constants"
|
||||
description: "History module, persist queue, document utilities, constants, and error types exported from @hyperframes/sdk."
|
||||
---
|
||||
|
||||
This page covers everything exported from `@hyperframes/sdk` that is not covered in [`openComposition`](/sdk/reference/open-composition), the [`Composition` interface](/sdk/reference/composition), [edit operations](/sdk/reference/edit-operations), [types](/sdk/reference/types), or [adapters](/sdk/reference/adapters).
|
||||
|
||||
---
|
||||
|
||||
## History Module
|
||||
|
||||
```typescript
|
||||
import { createHistory } from "@hyperframes/sdk";
|
||||
import type { HistoryModule, HistoryOptions, HistoryEntry } from "@hyperframes/sdk";
|
||||
```
|
||||
|
||||
Optional undo/redo module that wires onto a `Composition` session via the `"patch"` event. Standalone sessions created by `openComposition()` attach a history module automatically — you only need `createHistory` directly when you are building a host application that manages its own undo stack, or when you want non-default coalesce/depth settings.
|
||||
|
||||
### createHistory
|
||||
|
||||
```typescript
|
||||
function createHistory(session: Composition, opts?: HistoryOptions): HistoryModule;
|
||||
```
|
||||
|
||||
Subscribes to `session.on('patch')` and builds an undo/redo stack. Coalesces rapid same-operation bursts on the same element into a single undo entry so a slider drag produces one undo step, not hundreds.
|
||||
|
||||
```typescript
|
||||
import { openComposition, createHistory } from "@hyperframes/sdk";
|
||||
|
||||
// Custom history — host controls undo/redo buttons
|
||||
const comp = await openComposition(html, { history: false });
|
||||
const history = createHistory(comp, { coalesceMs: 500, maxEntries: 50 });
|
||||
|
||||
comp.setText("hf-title", "Draft v1");
|
||||
|
||||
history.canUndo(); // true
|
||||
history.undo(); // reverts to original
|
||||
history.redo(); // re-applies
|
||||
|
||||
history.dispose(); // unsubscribes from patch events
|
||||
```
|
||||
|
||||
### HistoryModule
|
||||
|
||||
```typescript
|
||||
interface HistoryModule {
|
||||
undo(): boolean;
|
||||
redo(): boolean;
|
||||
canUndo(): boolean;
|
||||
canRedo(): boolean;
|
||||
dispose(): void;
|
||||
}
|
||||
```
|
||||
|
||||
<ParamField path="undo" type="() => boolean">
|
||||
Pops the top entry from the undo stack and applies its inverse patches via `session.applyPatches()` tagged with `ORIGIN_APPLY_PATCHES`. Returns `true` when an entry was popped, `false` when the stack was empty.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="redo" type="() => boolean">
|
||||
Pops the top entry from the redo stack and re-applies its forward patches. Returns `true` when an entry was popped, `false` when the stack was empty. Any new op clears the redo stack.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="canUndo" type="() => boolean">
|
||||
Returns `true` when there is at least one entry on the undo stack.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="canRedo" type="() => boolean">
|
||||
Returns `true` when there is at least one entry on the redo stack.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="dispose" type="() => void">
|
||||
Unsubscribes from the session's `"patch"` event and clears both stacks. Call when the session is closed.
|
||||
</ParamField>
|
||||
|
||||
### HistoryOptions
|
||||
|
||||
```typescript
|
||||
interface HistoryOptions {
|
||||
trackedOrigins?: unknown[];
|
||||
coalesceMs?: number;
|
||||
maxEntries?: number;
|
||||
}
|
||||
```
|
||||
|
||||
<ParamField path="trackedOrigins" type="unknown[]">
|
||||
Only ops whose `origin` value appears in this array enter the undo stack. When omitted, all origins are tracked except `ORIGIN_APPLY_PATCHES` (which is always excluded to prevent undo loops). Use this to restrict the undo stack to UI-driven edits while letting programmatic patches pass through silently.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="coalesceMs" type="number">
|
||||
Window in milliseconds within which same-operation bursts on the same paths are merged into a single undo entry. The timestamp slides forward on each coalesced event, so continuous editing keeps merging until there is a gap longer than `coalesceMs`. Default: `300`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="maxEntries" type="number">
|
||||
Maximum depth of the undo stack. Oldest entries are dropped when the limit is exceeded. Default: `100`.
|
||||
</ParamField>
|
||||
|
||||
### HistoryEntry
|
||||
|
||||
```typescript
|
||||
interface HistoryEntry {
|
||||
readonly patches: readonly JsonPatchOp[];
|
||||
readonly inversePatches: readonly JsonPatchOp[];
|
||||
readonly opTypes: readonly string[];
|
||||
readonly origin: unknown;
|
||||
readonly timestamp: number;
|
||||
}
|
||||
```
|
||||
|
||||
Entries are internal to the history module — you do not create them directly. They mirror the shape of `PatchEvent` fields so the history module can reconstruct the forward and inverse change sets without re-parsing.
|
||||
|
||||
<Note>
|
||||
Standalone sessions (the default) attach history automatically. Pass `{ history: false }` to `openComposition()` when you want to manage undo/redo yourself via `createHistory` or the host's own stack.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Persist Queue
|
||||
|
||||
```typescript
|
||||
import { createPersistQueue } from "@hyperframes/sdk";
|
||||
import type { PersistQueueModule, PersistQueueOptions } from "@hyperframes/sdk";
|
||||
```
|
||||
|
||||
Optional module that subscribes to `"change"` events on a session and schedules async writes through a `PersistAdapter`. One write is in flight at a time; the latest HTML always wins (last-write-wins coalescing). Standalone sessions wired with a `persist` adapter attach this automatically via `openComposition()`. Use `createPersistQueue` directly only when you are building an embedded host that owns persistence separately from the SDK session.
|
||||
|
||||
### createPersistQueue
|
||||
|
||||
```typescript
|
||||
function createPersistQueue(
|
||||
session: Composition,
|
||||
adapter: PersistAdapter,
|
||||
opts?: PersistQueueOptions,
|
||||
): PersistQueueModule;
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { openComposition, createPersistQueue } from "@hyperframes/sdk";
|
||||
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
|
||||
|
||||
const adapt = createFsAdapter({ root: "./project" });
|
||||
const comp = await openComposition(html, { history: false });
|
||||
|
||||
const queue = createPersistQueue(comp, adapt, {
|
||||
path: "my-comp.html",
|
||||
onError: ({ error }) => console.error("Write failed:", error.message),
|
||||
});
|
||||
|
||||
comp.setText("hf-title", "Autosaved");
|
||||
|
||||
// Force immediate flush before app close
|
||||
await queue.flush();
|
||||
queue.dispose();
|
||||
```
|
||||
|
||||
### PersistQueueModule
|
||||
|
||||
```typescript
|
||||
interface PersistQueueModule {
|
||||
flush(): Promise<void>;
|
||||
dispose(): void;
|
||||
}
|
||||
```
|
||||
|
||||
<ParamField path="flush" type="() => Promise<void>">
|
||||
Cancels any pending debounced write and immediately writes the current serialized HTML to the adapter. Resolves when the write commits. Use before app close or page unload.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="dispose" type="() => void">
|
||||
Cancels any pending write and unsubscribes from the session's `"change"` event. Does not flush — call `flush()` first if you need to ensure the final state is written.
|
||||
</ParamField>
|
||||
|
||||
### PersistQueueOptions
|
||||
|
||||
```typescript
|
||||
interface PersistQueueOptions {
|
||||
path?: string;
|
||||
onError?: (e: PersistErrorEvent) => void;
|
||||
}
|
||||
```
|
||||
|
||||
<ParamField path="path" type="string">
|
||||
The adapter path to write to. Passed directly to `adapter.write(path, content)`. Default: `"composition.html"`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="onError" type="(e: PersistErrorEvent) => void">
|
||||
Called when `adapter.write()` rejects. Receives a `PersistErrorEvent` with `{ error: { message, cause? } }`. Use this to surface persistence failures in your UI or logging layer.
|
||||
</ParamField>
|
||||
|
||||
---
|
||||
|
||||
## Document Utilities
|
||||
|
||||
```typescript
|
||||
import { buildDocument, buildRoots, flatElements } from "@hyperframes/sdk";
|
||||
```
|
||||
|
||||
Low-level utilities for building the `SdkDocument` model and `HyperFramesElement` trees from parsed HTML. These are the same functions the SDK uses internally on every `openComposition()` call. You rarely need them directly — they are exposed for hosts that parse HTML outside of a session, or for testing.
|
||||
|
||||
### buildDocument
|
||||
|
||||
```typescript
|
||||
function buildDocument(html: string): SdkDocument;
|
||||
```
|
||||
|
||||
Parses an HTML string into the SDK document model. Calls `ensureHfIds` first so every element gets a stable `data-hf-id`. Uses `linkedom` for DOM parsing — node-safe, works in agents, CI, and server-side code. Returns an `SdkDocument` snapshot; mutations on the live session do not update the returned value.
|
||||
|
||||
### buildRoots
|
||||
|
||||
```typescript
|
||||
function buildRoots(document: Document): HyperFramesElement[];
|
||||
```
|
||||
|
||||
Builds the element tree from an already-parsed (hf-id-stamped) `linkedom` `Document`. Walks the live DOM directly — no serialize/re-parse round trip. This is what the session's query API uses against its mutable document after each op.
|
||||
|
||||
### flatElements
|
||||
|
||||
```typescript
|
||||
function flatElements(roots: readonly HyperFramesElement[]): HyperFramesElement[];
|
||||
```
|
||||
|
||||
Returns every element from `roots` and all their descendants in document order (depth-first pre-order). Useful for searching across the full element tree without writing your own recursive walk.
|
||||
|
||||
```typescript
|
||||
import { buildDocument, flatElements } from "@hyperframes/sdk";
|
||||
|
||||
const doc = buildDocument(html);
|
||||
const all = flatElements(doc.roots);
|
||||
const images = all.filter((el) => el.tag === "img");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constants
|
||||
|
||||
### ORIGIN\_APPLY\_PATCHES
|
||||
|
||||
```typescript
|
||||
import { ORIGIN_APPLY_PATCHES } from "@hyperframes/sdk";
|
||||
|
||||
const ORIGIN_APPLY_PATCHES = "@hyperframes/sdk:applyPatches";
|
||||
```
|
||||
|
||||
Reserved origin tag emitted by `applyPatches()` and by the history module's `undo()` / `redo()` methods. Host patch listeners **must** skip this origin to avoid undo loops:
|
||||
|
||||
```typescript
|
||||
comp.on("patch", ({ origin, patches }) => {
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return; // SDK-internal replay — skip
|
||||
forwardToCollaborationLayer(patches);
|
||||
});
|
||||
```
|
||||
|
||||
The value is a namespaced string rather than a `Symbol` so it survives realm boundaries — `postMessage`, structured clone, and JSON serialization all preserve it correctly. T3 embedded hosts that forward patch events across frames or workers rely on this property. The namespace prefix (`@hyperframes/sdk:`) makes accidental collision with a host-chosen origin string negligible.
|
||||
|
||||
### ORIGIN\_LOCAL
|
||||
|
||||
```typescript
|
||||
import { ORIGIN_LOCAL } from "@hyperframes/sdk";
|
||||
|
||||
const ORIGIN_LOCAL = "local";
|
||||
```
|
||||
|
||||
Default origin applied when you call typed methods (`setText`, `setStyle`, …) or `dispatch()` without an explicit `origin` option. You can filter on `"local"` to track only user-driven UI edits in a history module's `trackedOrigins` list.
|
||||
|
||||
---
|
||||
|
||||
## Errors
|
||||
|
||||
### UnsupportedOpError
|
||||
|
||||
```typescript
|
||||
import { UnsupportedOpError } from "@hyperframes/sdk";
|
||||
```
|
||||
|
||||
Thrown by `dispatch()` when an op type is not handled by the current engine version. The `code` property is stable and part of the public API contract — switch on it rather than the message string.
|
||||
|
||||
```typescript
|
||||
class UnsupportedOpError extends Error {
|
||||
readonly code = "E_UNSUPPORTED_OP";
|
||||
}
|
||||
```
|
||||
|
||||
Feature-detect before dispatching optional ops with `comp.can(op)` to avoid this error in the hot path:
|
||||
|
||||
```typescript
|
||||
const result = comp.can({ type: "setGsapTween", animationId: id, properties: { ease: "power2.out" } });
|
||||
if (!result.ok) {
|
||||
console.warn(result.message);
|
||||
return;
|
||||
}
|
||||
comp.setGsapTween(id, { ease: "power2.out" });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## resolveNearestHfElement
|
||||
|
||||
```typescript
|
||||
import { resolveNearestHfElement } from "@hyperframes/sdk";
|
||||
|
||||
function resolveNearestHfElement(
|
||||
el: Element | null,
|
||||
isVisible: (el: Element) => boolean,
|
||||
): ElementAtPointResult | null;
|
||||
```
|
||||
|
||||
Walks from `el` upward through `parentElement`, returning the nearest ancestor (inclusive) that carries `[data-hf-id]` and is not `[data-hf-root]`. Returns `null` when the walk exits the DOM without finding a match, when the matched node is the composition root, or when `isVisible(node)` returns `false` for the matched node.
|
||||
|
||||
This is a pure function (no `window` or DOM API calls beyond `getAttribute`) and is unit-testable in a plain Node environment. `createIframePreviewAdapter` uses it internally to translate raw hit-test results into SDK element IDs. Full treatment of hit-testing and the visual editor canvas pattern is in the [Canvas Integration guide](/sdk/guides/canvas-integration).
|
||||
|
||||
---
|
||||
|
||||
## resolveElementAffordances
|
||||
|
||||
```typescript
|
||||
import { resolveElementAffordances } from "@hyperframes/sdk/editing";
|
||||
```
|
||||
|
||||
Determines which editing operations are available for a live element given its current DOM state and model. Imported from the `@hyperframes/sdk/editing` subpath. Full documentation is in the [Editing Affordances guide](/sdk/guides/editing-affordances).
|
||||
|
||||
---
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Undo, Redo & Patches" icon="clock-rotate-left" href="/sdk/guides/undo-redo-and-patches">
|
||||
How the history module, ORIGIN_APPLY_PATCHES, and applyPatches() work together.
|
||||
</Card>
|
||||
<Card title="Persistence" icon="floppy-disk" href="/sdk/guides/persistence">
|
||||
Wiring a PersistAdapter, handling errors, and restoring from version history.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@
|
||||
"player:perf": "bun run --filter @hyperframes/player perf",
|
||||
"format:check": "oxfmt --check .",
|
||||
"knip": "knip",
|
||||
"test:scripts": "node --import tsx --test scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts",
|
||||
"test:scripts": "node --import tsx --test scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/verify-packed-manifests.test.mjs",
|
||||
"test:skills": "node --test 'skills/**/*.test.mjs'",
|
||||
"generate:previews": "tsx scripts/generate-template-previews.ts",
|
||||
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/aws-lambda",
|
||||
"version": "0.7.18",
|
||||
"version": "0.7.42",
|
||||
"description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* CJS-interop banner prepended to the ESM handler bundle.
|
||||
*
|
||||
* Lambda's Node 22 runtime treats `.mjs` as ESM, which has no `require`,
|
||||
* `__filename`, or `__dirname`. The handler bundle inlines CJS deps that
|
||||
* assume all three exist at module scope:
|
||||
* - postcss et al. call top-level `require(...)`
|
||||
* - wawoff2's emscripten build (pulled in unconditionally via
|
||||
* producer → fontCompression) reads `__dirname` at module scope
|
||||
* Without the shims the handler throws "Dynamic require of <X> is not
|
||||
* supported" / "__dirname is not defined in ES module scope" at import time,
|
||||
* before it can run — which is exactly how a freshly deployed stack crashed
|
||||
* on every render (#1932).
|
||||
*
|
||||
* This mirrors the producer's own CJS banner (packages/producer/build.mjs);
|
||||
* the handler bundle inlines producer source, so it needs the same shim.
|
||||
*
|
||||
* Kept in its own module (not inline in build-zip.ts, which self-executes on
|
||||
* import) so build-zip.test.ts can import the exact banner, bundle a fixture
|
||||
* with it, and assert the globals actually resolve.
|
||||
*/
|
||||
export const HANDLER_BANNER = [
|
||||
"// hyperframes-aws-lambda handler bundle",
|
||||
'import { createRequire as __hf_createRequire } from "module";',
|
||||
'import { fileURLToPath as __hf_fileURLToPath } from "url";',
|
||||
'import { dirname as __hf_dirname } from "path";',
|
||||
"const require = __hf_createRequire(import.meta.url);",
|
||||
"const __filename = __hf_fileURLToPath(import.meta.url);",
|
||||
"const __dirname = __hf_dirname(__filename);",
|
||||
].join("\n");
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import * as esbuild from "esbuild";
|
||||
import { HANDLER_BANNER } from "./_handlerBanner.js";
|
||||
|
||||
// The handler ships as ESM (.mjs) but inlines CJS deps that assume Node's CJS
|
||||
// globals exist at module scope: postcss et al. call top-level `require(...)`,
|
||||
// and wawoff2's emscripten build reads `__dirname`. A freshly deployed stack
|
||||
// crashed on every render (#1932) with "__dirname is not defined in ES module
|
||||
// scope"; the fix is the require/__filename/__dirname shim in HANDLER_BANNER.
|
||||
//
|
||||
// This bundles a fixture touching all three globals with the REAL banner and
|
||||
// imports the output, so it catches a dropped/renamed shim behaviourally
|
||||
// rather than by grepping for literals (which survives a broken refactor).
|
||||
//
|
||||
// The import MUST run under Node, not the `bun test` runtime: Bun defines
|
||||
// `__dirname`/`__filename` even in ESM, which would mask a missing shim and
|
||||
// green-light a broken bundle. Lambda runs Node, so we spawn `node` (guaranteed
|
||||
// present in CI alongside bun) to reproduce the deploy target faithfully.
|
||||
describe("build-zip handler banner", () => {
|
||||
it("shims require/__filename/__dirname so inlined CJS deps import under Node", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-banner-test-"));
|
||||
try {
|
||||
const entry = join(dir, "fixture.ts");
|
||||
const outfile = join(dir, "out.mjs");
|
||||
// Reference each CJS global at module top level, the way inlined deps do.
|
||||
// If any shim is missing, importing `out.mjs` throws at eval time.
|
||||
writeFileSync(
|
||||
entry,
|
||||
[
|
||||
"const cjsDir = __dirname;",
|
||||
"const cjsFile = __filename;",
|
||||
"const path = require('node:path');",
|
||||
"if (typeof cjsDir !== 'string') throw new Error('__dirname missing');",
|
||||
"if (typeof cjsFile !== 'string') throw new Error('__filename missing');",
|
||||
"if (typeof path.join !== 'function') throw new Error('require missing');",
|
||||
"console.log('BANNER_OK');",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
esbuild.buildSync({
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
format: "esm",
|
||||
entryPoints: [entry],
|
||||
outfile,
|
||||
banner: { js: HANDLER_BANNER },
|
||||
});
|
||||
|
||||
// Import under real Node — Lambda's runtime — not the bun test runtime.
|
||||
const res = spawnSync(
|
||||
"node",
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`await import(${JSON.stringify(pathToFileURL(outfile).href)});`,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
// A missing shim surfaces as a non-zero exit + ReferenceError on stderr.
|
||||
// Guard against a silent skip if `node` isn't on PATH (it is in CI).
|
||||
expect(res.error).toBeUndefined();
|
||||
expect(res.stderr).not.toContain("is not defined in ES module scope");
|
||||
expect(res.stderr).not.toContain("Dynamic require");
|
||||
expect(res.status).toBe(0);
|
||||
expect(res.stdout).toContain("BANNER_OK");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as esbuild from "esbuild";
|
||||
import { formatBytes } from "./_formatBytes.js";
|
||||
import { HANDLER_BANNER } from "./_handlerBanner.js";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const packageRoot = resolve(scriptDir, "..");
|
||||
@@ -239,20 +240,10 @@ async function bundleHandler(stagingDir: string): Promise<void> {
|
||||
sourcemap: false,
|
||||
entryPoints: [entry],
|
||||
outfile,
|
||||
// Lambda's Node 22 runtime treats `.mjs` as ESM. Inject a real `require`
|
||||
// via `createRequire` so esbuild's `__require` shim resolves to it
|
||||
// instead of throwing "Dynamic require of <X> is not supported" on
|
||||
// CommonJS modules in the dependency graph (postcss, etc. that ship
|
||||
// top-level `require('path')` calls). The shim does
|
||||
// `typeof require !== "undefined" ? require : <throwing-proxy>`, so
|
||||
// making `require` a real value in module scope flips it onto the
|
||||
// happy path.
|
||||
// See HANDLER_BANNER (_handlerBanner.ts) for why the ESM bundle needs the
|
||||
// CJS require/__filename/__dirname shims.
|
||||
banner: {
|
||||
js: [
|
||||
"// hyperframes-aws-lambda handler bundle",
|
||||
'import { createRequire as __hf_createRequire } from "module";',
|
||||
"const require = __hf_createRequire(import.meta.url);",
|
||||
].join("\n"),
|
||||
js: HANDLER_BANNER,
|
||||
},
|
||||
});
|
||||
console.log(`[build-zip] bundled handler → ${outfile}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/cli",
|
||||
"version": "0.7.18",
|
||||
"version": "0.7.42",
|
||||
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Which voice / music engine a workflow will actually use, and whether
|
||||
* its local dependencies are present. Mirrors the resolution order the
|
||||
* hyperframes-media skill scripts use, so `auth status` and `doctor`
|
||||
* media-use skill scripts use, so `auth status` and `doctor`
|
||||
* report the same engine the render pipeline would pick:
|
||||
*
|
||||
* voice: HeyGen Starfish → ElevenLabs (key + `elevenlabs`) → Kokoro (local)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { c } from "../ui/colors.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
|
||||
export interface OpenBrowserResult {
|
||||
/** True when we successfully invoked the platform "open" command. */
|
||||
@@ -21,7 +22,7 @@ export async function openBrowser(url: string): Promise<OpenBrowserResult> {
|
||||
await open(url);
|
||||
return { opened: true };
|
||||
} catch (err) {
|
||||
printManualInstructions(url, err instanceof Error ? err.message : String(err));
|
||||
printManualInstructions(url, normalizeErrorMessage(err));
|
||||
return { opened: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:child_process", () => ({ execSync: vi.fn() }));
|
||||
@@ -23,6 +24,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("findFFmpeg", () => {
|
||||
it("prefers the real Windows exe when where lists a cmd shim first", async () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||
mockExec.mockReturnValue("C:\\tools\\ffmpeg.cmd\r\nC:\\tools\\ffmpeg.exe\r\n");
|
||||
|
||||
const { findFFmpeg } = await import("./ffmpeg.js");
|
||||
expect(findFFmpeg()).toBe(resolve("C:\\tools\\ffmpeg.exe"));
|
||||
});
|
||||
|
||||
it("falls back to a common install dir when `which` fails (GUI-launched PATH)", async () => {
|
||||
// Simulate a process whose PATH lacks /opt/homebrew/bin: `which ffmpeg` throws.
|
||||
mockExec.mockImplementation(() => {
|
||||
|
||||
@@ -2,10 +2,31 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { detectLinuxDistro, ffmpegInstallCommand } from "./linuxDeps.js";
|
||||
|
||||
export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
|
||||
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
|
||||
|
||||
function chooseBestPathCandidate(
|
||||
name: "ffmpeg" | "ffprobe",
|
||||
candidates: string[],
|
||||
): string | undefined {
|
||||
const normalized = candidates.map((s) => s.trim()).filter(Boolean);
|
||||
if (normalized.length === 0) return undefined;
|
||||
const lowerName = name.toLowerCase();
|
||||
const preferredExe = normalized.find((candidate) =>
|
||||
candidate.toLowerCase().endsWith(`${lowerName}.exe`),
|
||||
);
|
||||
if (preferredExe) return preferredExe;
|
||||
const exact = normalized.find((candidate) => candidate.toLowerCase().endsWith(lowerName));
|
||||
if (exact) return exact;
|
||||
const nonShellShim = normalized.find((candidate) => {
|
||||
const lower = candidate.toLowerCase();
|
||||
return !lower.endsWith(".cmd") && !lower.endsWith(".bat");
|
||||
});
|
||||
return nonShellShim ?? normalized[0];
|
||||
}
|
||||
|
||||
function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
|
||||
try {
|
||||
const cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`;
|
||||
@@ -14,11 +35,8 @@ function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
timeout: 5000,
|
||||
});
|
||||
const first = output
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.find(Boolean);
|
||||
return first ? resolve(first) : undefined;
|
||||
const candidate = chooseBestPathCandidate(name, output.split(/\r?\n/));
|
||||
return candidate ? resolve(candidate) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -62,8 +80,12 @@ export function getFFmpegInstallHint(): string {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return "brew install ffmpeg";
|
||||
case "linux":
|
||||
return "sudo apt install ffmpeg";
|
||||
case "linux": {
|
||||
// Distro-aware so WSL/Fedora/Arch/Alpine users get a command that
|
||||
// actually works instead of a Debian-only `apt` line.
|
||||
const distro = detectLinuxDistro();
|
||||
return ffmpegInstallCommand(distro.family);
|
||||
}
|
||||
case "win32":
|
||||
return "Download the 64-bit Windows build from https://ffmpeg.org/download.html#build-windows and add its bin/ directory to PATH.";
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
chromeDepsInstallCommand,
|
||||
chromeLaunchRemediation,
|
||||
distroFamilyFromOsRelease,
|
||||
distroLabel,
|
||||
ffmpegInstallCommand,
|
||||
isSharedLibLaunchError,
|
||||
parseLddMissingLibs,
|
||||
parseOsRelease,
|
||||
} from "./linuxDeps.js";
|
||||
|
||||
describe("parseOsRelease", () => {
|
||||
it("parses quoted and unquoted key=value pairs", () => {
|
||||
const parsed = parseOsRelease(
|
||||
['NAME="Ubuntu"', "ID=ubuntu", 'ID_LIKE="debian"', 'PRETTY_NAME="Ubuntu 22.04.3 LTS"'].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
expect(parsed["ID"]).toBe("ubuntu");
|
||||
expect(parsed["ID_LIKE"]).toBe("debian");
|
||||
expect(parsed["PRETTY_NAME"]).toBe("Ubuntu 22.04.3 LTS");
|
||||
});
|
||||
|
||||
it("ignores comments and blank lines", () => {
|
||||
const parsed = parseOsRelease("# a comment\n\nID=fedora\n");
|
||||
expect(parsed).toEqual({ ID: "fedora" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("distroFamilyFromOsRelease", () => {
|
||||
it("maps Ubuntu/Debian derivatives to debian", () => {
|
||||
expect(distroFamilyFromOsRelease("ubuntu", "debian")).toBe("debian");
|
||||
expect(distroFamilyFromOsRelease("debian")).toBe("debian");
|
||||
expect(distroFamilyFromOsRelease("linuxmint", "ubuntu debian")).toBe("debian");
|
||||
});
|
||||
|
||||
it("maps RHEL family to fedora", () => {
|
||||
expect(distroFamilyFromOsRelease("fedora")).toBe("fedora");
|
||||
expect(distroFamilyFromOsRelease("rocky", "rhel centos fedora")).toBe("fedora");
|
||||
expect(distroFamilyFromOsRelease("amzn")).toBe("fedora");
|
||||
});
|
||||
|
||||
it("maps Arch derivatives to arch", () => {
|
||||
expect(distroFamilyFromOsRelease("arch")).toBe("arch");
|
||||
expect(distroFamilyFromOsRelease("manjaro", "arch")).toBe("arch");
|
||||
});
|
||||
|
||||
it("maps Alpine to alpine", () => {
|
||||
expect(distroFamilyFromOsRelease("alpine")).toBe("alpine");
|
||||
});
|
||||
|
||||
it("returns unknown for unrecognized ids", () => {
|
||||
expect(distroFamilyFromOsRelease("void")).toBe("unknown");
|
||||
expect(distroFamilyFromOsRelease(undefined, undefined)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chromeDepsInstallCommand", () => {
|
||||
it("emits an apt-get line with libnss3 for debian", () => {
|
||||
const cmd = chromeDepsInstallCommand("debian");
|
||||
expect(cmd).toContain("apt-get install -y");
|
||||
expect(cmd).toContain("libnss3");
|
||||
expect(cmd).toContain("libatk1.0-0");
|
||||
});
|
||||
|
||||
it("emits a dnf line with nss for fedora", () => {
|
||||
const cmd = chromeDepsInstallCommand("fedora");
|
||||
expect(cmd).toContain("dnf install -y");
|
||||
expect(cmd).toContain("nss");
|
||||
});
|
||||
|
||||
it("emits a pacman line for arch", () => {
|
||||
expect(chromeDepsInstallCommand("arch")).toContain("pacman -S");
|
||||
});
|
||||
|
||||
it("emits an apk line for alpine", () => {
|
||||
expect(chromeDepsInstallCommand("alpine")).toContain("apk add");
|
||||
});
|
||||
|
||||
it("gives generic guidance for unknown distros", () => {
|
||||
expect(chromeDepsInstallCommand("unknown").toLowerCase()).toContain("nss");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ffmpegInstallCommand", () => {
|
||||
it("uses the distro package manager", () => {
|
||||
expect(ffmpegInstallCommand("debian")).toBe(
|
||||
"sudo apt-get update && sudo apt-get install -y ffmpeg",
|
||||
);
|
||||
expect(ffmpegInstallCommand("fedora")).toBe("sudo dnf install -y ffmpeg");
|
||||
expect(ffmpegInstallCommand("arch")).toBe("sudo pacman -S --needed ffmpeg");
|
||||
expect(ffmpegInstallCommand("alpine")).toBe("sudo apk add ffmpeg");
|
||||
});
|
||||
|
||||
it("gives generic guidance for unknown distros", () => {
|
||||
expect(ffmpegInstallCommand("unknown").toLowerCase()).toContain("ffmpeg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLddMissingLibs", () => {
|
||||
it("collects libraries reported as not found", () => {
|
||||
const output = [
|
||||
"\tlinux-vdso.so.1 (0x00007fff...)",
|
||||
"\tlibnss3.so => not found",
|
||||
"\tlibatk-1.0.so.0 => not found",
|
||||
"\tlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)",
|
||||
].join("\n");
|
||||
const result = parseLddMissingLibs(output);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.missing).toEqual(["libnss3.so", "libatk-1.0.so.0"]);
|
||||
expect(result.probeUnavailable).toBe(false);
|
||||
});
|
||||
|
||||
it("reports ok when every library resolves", () => {
|
||||
const output = "\tlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)";
|
||||
const result = parseLddMissingLibs(output);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not false-positive on a resolved path containing 'not found'", () => {
|
||||
// A directory literally named "not found" must not trip the missing-lib
|
||||
// detection — only the `=> not found` marker counts.
|
||||
const output = "\tlibfoo.so.1 => /opt/not found/libfoo.so.1 (0x00007f...)";
|
||||
const result = parseLddMissingLibs(output);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("distroLabel", () => {
|
||||
it("returns WSL when running under WSL", () => {
|
||||
expect(distroLabel({ family: "debian", prettyName: "Ubuntu", isWsl: true })).toBe("WSL");
|
||||
});
|
||||
|
||||
it("returns the pretty name off WSL", () => {
|
||||
expect(distroLabel({ family: "fedora", prettyName: "Fedora Linux 40", isWsl: false })).toBe(
|
||||
"Fedora Linux 40",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to Linux when no pretty name", () => {
|
||||
expect(distroLabel({ family: "unknown", isWsl: false })).toBe("Linux");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSharedLibLaunchError", () => {
|
||||
it("matches the libnss3 cannot-open message", () => {
|
||||
expect(
|
||||
isSharedLibLaunchError(
|
||||
"libnss3.so: cannot open shared object file: No such file or directory",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the dynamic-linker phrasing", () => {
|
||||
expect(isSharedLibLaunchError("error while loading shared libraries: libatk-1.0.so.0")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not match unrelated errors", () => {
|
||||
expect(isSharedLibLaunchError("Composition has zero duration")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chromeLaunchRemediation", () => {
|
||||
it("returns undefined for non-launch errors", () => {
|
||||
expect(chromeLaunchRemediation("Composition HTML is empty")).toBeUndefined();
|
||||
});
|
||||
|
||||
// Platform-dependent output (Linux distro detection) is covered by the
|
||||
// preflight tests that mock `platform()`; here we only assert the non-Linux /
|
||||
// non-launch short-circuits, which are deterministic on the macOS CI host.
|
||||
it("returns undefined off Linux even for a launch failure", () => {
|
||||
if (process.platform === "linux") return;
|
||||
expect(chromeLaunchRemediation("Failed to launch the browser process")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { detectWSL } from "../telemetry/platform.js";
|
||||
|
||||
/**
|
||||
* Linux/WSL Chrome & ffmpeg dependency detection and remediation.
|
||||
*
|
||||
* WSL first-render success is only ~34.7% (vs ~53% local_agent) — the dominant
|
||||
* cause is a downloaded `chrome-headless-shell` that launches into
|
||||
* `libnss3.so: cannot open shared object file` (and friends) because the
|
||||
* headless Chromium shared-library set is not installed. A binary existing on
|
||||
* disk is NOT sufficient; `doctor`/preflight must verify it can actually load
|
||||
* its shared libraries and, when it can't, print the exact per-distro install
|
||||
* command.
|
||||
*
|
||||
* Design decision: detect + print precise remediation, do NOT auto-install.
|
||||
* Auto-install needs sudo + network and is surprising in an agent/CI context;
|
||||
* remediation is a copy-paste line the user (or their provisioning script) runs.
|
||||
*/
|
||||
|
||||
export type LinuxDistroFamily = "debian" | "fedora" | "arch" | "alpine" | "unknown";
|
||||
|
||||
export interface LinuxDistroInfo {
|
||||
/** Package-manager family used to pick the install command. */
|
||||
family: LinuxDistroFamily;
|
||||
/** `ID` from /etc/os-release (e.g. "ubuntu", "debian", "fedora"), if any. */
|
||||
id?: string;
|
||||
/** Human-readable name from /etc/os-release `PRETTY_NAME`, if any. */
|
||||
prettyName?: string;
|
||||
/** True when running under Windows Subsystem for Linux. */
|
||||
isWsl: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full per-distro package list that provides the headless Chrome dependency
|
||||
* set. Kept as the complete set (not just the missing ones) so the remediation
|
||||
* line is a single deterministic, copy-pasteable command that fixes the whole
|
||||
* class of failure in one shot rather than one library at a time.
|
||||
*
|
||||
* The headless Chrome shared-library set (libnss3, libnspr4, libatk,
|
||||
* at-spi2, cups, libdrm, libxkbcommon, gbm, pango, cairo, alsa, ...) surfaces
|
||||
* at launch as `error while loading shared libraries: <lib>: cannot open shared
|
||||
* object file`, which Puppeteer wraps as `Failed to launch the browser process`.
|
||||
* `ldd` reports the exact missing `.so`; the package list below is what provides
|
||||
* them.
|
||||
*/
|
||||
const DISTRO_PACKAGES: Record<Exclude<LinuxDistroFamily, "unknown">, string[]> = {
|
||||
debian: [
|
||||
"libnss3",
|
||||
"libnspr4",
|
||||
"libatk1.0-0",
|
||||
"libatk-bridge2.0-0",
|
||||
"libcups2",
|
||||
"libdrm2",
|
||||
"libxkbcommon0",
|
||||
"libatspi2.0-0",
|
||||
"libxcomposite1",
|
||||
"libxdamage1",
|
||||
"libxfixes3",
|
||||
"libxrandr2",
|
||||
"libgbm1",
|
||||
"libpango-1.0-0",
|
||||
"libcairo2",
|
||||
"libasound2",
|
||||
],
|
||||
fedora: [
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libxkbcommon",
|
||||
"at-spi2-core",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXfixes",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"cairo",
|
||||
"alsa-lib",
|
||||
],
|
||||
arch: [
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"libcups",
|
||||
"libdrm",
|
||||
"libxkbcommon",
|
||||
"at-spi2-core",
|
||||
"libxcomposite",
|
||||
"libxdamage",
|
||||
"libxfixes",
|
||||
"libxrandr",
|
||||
"mesa",
|
||||
"pango",
|
||||
"cairo",
|
||||
"alsa-lib",
|
||||
],
|
||||
alpine: [
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libxkbcommon",
|
||||
"at-spi2-core",
|
||||
"libxcomposite",
|
||||
"libxdamage",
|
||||
"libxfixes",
|
||||
"libxrandr",
|
||||
"mesa-gbm",
|
||||
"pango",
|
||||
"cairo",
|
||||
"alsa-lib",
|
||||
],
|
||||
};
|
||||
|
||||
const INSTALL_PREFIX: Record<Exclude<LinuxDistroFamily, "unknown">, string> = {
|
||||
debian: "sudo apt-get update && sudo apt-get install -y",
|
||||
fedora: "sudo dnf install -y",
|
||||
arch: "sudo pacman -S --needed",
|
||||
alpine: "sudo apk add",
|
||||
};
|
||||
|
||||
/**
|
||||
* Map an /etc/os-release `ID` / `ID_LIKE` to a package-manager family.
|
||||
* Exported for direct unit testing without touching the filesystem.
|
||||
*/
|
||||
export function distroFamilyFromOsRelease(id?: string, idLike?: string): LinuxDistroFamily {
|
||||
const haystack = `${id ?? ""} ${idLike ?? ""}`.toLowerCase();
|
||||
if (/\b(debian|ubuntu|linuxmint|pop|elementary|raspbian|kali)\b/.test(haystack)) return "debian";
|
||||
if (/\b(fedora|rhel|centos|rocky|almalinux|amzn|ol)\b/.test(haystack)) return "fedora";
|
||||
if (/\b(arch|manjaro|endeavouros|garuda)\b/.test(haystack)) return "arch";
|
||||
if (/\b(alpine)\b/.test(haystack)) return "alpine";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** Parse the shell-style key=value contents of /etc/os-release. */
|
||||
export function parseOsRelease(contents: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const rawLine of contents.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
let value = line.slice(eq + 1).trim();
|
||||
// Strip surrounding single/double quotes.
|
||||
if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0]) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (key) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the running Linux distribution family and WSL status. Reads
|
||||
* /etc/os-release; falls back to "unknown" when it can't be read or matched.
|
||||
*/
|
||||
export function detectLinuxDistro(): LinuxDistroInfo {
|
||||
const isWsl = detectWSL();
|
||||
try {
|
||||
const contents = readFileSync("/etc/os-release", "utf-8");
|
||||
const parsed = parseOsRelease(contents);
|
||||
const family = distroFamilyFromOsRelease(parsed["ID"], parsed["ID_LIKE"]);
|
||||
return {
|
||||
family,
|
||||
...(parsed["ID"] ? { id: parsed["ID"] } : {}),
|
||||
...(parsed["PRETTY_NAME"] ? { prettyName: parsed["PRETTY_NAME"] } : {}),
|
||||
isWsl,
|
||||
};
|
||||
} catch {
|
||||
return { family: "unknown", isWsl };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the exact command to install the full Chrome shared-library set for a
|
||||
* distro family, or a generic pointer for unknown families.
|
||||
*/
|
||||
export function chromeDepsInstallCommand(family: LinuxDistroFamily): string {
|
||||
if (family === "unknown") {
|
||||
return "Install the Chrome headless dependencies for your distro (nss, atk, at-spi2, cups, libdrm, libxkbcommon, gbm, pango, cairo, alsa), then re-run.";
|
||||
}
|
||||
return `${INSTALL_PREFIX[family]} ${DISTRO_PACKAGES[family].join(" ")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-distro ffmpeg install command (ffprobe ships in the same `ffmpeg` package
|
||||
* on every family we support).
|
||||
*/
|
||||
export function ffmpegInstallCommand(family: LinuxDistroFamily): string {
|
||||
if (family === "unknown") {
|
||||
return "Install ffmpeg (which includes ffprobe) via your distro package manager, then re-run.";
|
||||
}
|
||||
return `${INSTALL_PREFIX[family]} ffmpeg`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable environment label for a detected distro — "WSL" when running
|
||||
* under WSL, else the /etc/os-release PRETTY_NAME, else "Linux". Shared so the
|
||||
* preflight check and the render-failure remediation name the environment
|
||||
* identically for the same machine.
|
||||
*/
|
||||
export function distroLabel(distro: LinuxDistroInfo): string {
|
||||
if (distro.isWsl) return "WSL";
|
||||
return distro.prettyName ?? "Linux";
|
||||
}
|
||||
|
||||
export interface SharedLibProbeResult {
|
||||
/** True when the probe ran and every required library resolved. */
|
||||
ok: boolean;
|
||||
/** Shared libs reported by `ldd` as "not found". Empty when ok. */
|
||||
missing: string[];
|
||||
/**
|
||||
* True when the probe itself could not run (no `ldd`, non-Linux, exec
|
||||
* failure). Distinct from `ok:false` — we can't conclude libs are missing, so
|
||||
* callers should not fabricate a false "Chrome broken" error.
|
||||
*/
|
||||
probeUnavailable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a child-process error indicates the process was killed (e.g. by the
|
||||
* `timeout` option → SIGTERM), whose stdout is only a partial capture.
|
||||
*/
|
||||
function isKilledExecError(err: unknown): boolean {
|
||||
if (typeof err !== "object" || err === null) return false;
|
||||
const e = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string | null };
|
||||
return e.killed === true || e.signal != null;
|
||||
}
|
||||
|
||||
/** Extract captured stdout from a child-process error, if present, as a string. */
|
||||
function execErrorStdout(err: unknown): string | undefined {
|
||||
if (typeof err !== "object" || err === null || !("stdout" in err)) return undefined;
|
||||
const stdout = (err as { stdout?: string | Buffer | null }).stdout;
|
||||
if (stdout == null) return undefined;
|
||||
return stdout.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `ldd <chromeBinary>` and report any shared libraries the dynamic linker
|
||||
* cannot resolve (lines containing "=> not found"). This is the check that
|
||||
* catches the `libnss3.so: cannot open shared object file` launch failure
|
||||
* BEFORE a render is attempted — a binary can exist on disk yet be unlaunchable.
|
||||
*
|
||||
* Only meaningful on Linux. Returns `probeUnavailable:true` on any platform
|
||||
* where `ldd` isn't applicable or the probe can't run, so callers treat it as
|
||||
* "inconclusive" rather than "missing".
|
||||
*/
|
||||
export function probeChromeSharedLibs(chromeBinaryPath: string): SharedLibProbeResult {
|
||||
if (process.platform !== "linux") {
|
||||
return { ok: true, missing: [], probeUnavailable: true };
|
||||
}
|
||||
if (!existsSync(chromeBinaryPath)) {
|
||||
return { ok: true, missing: [], probeUnavailable: true };
|
||||
}
|
||||
let output: string;
|
||||
try {
|
||||
// ldd exits non-zero when libs are missing but still prints the resolution
|
||||
// table to stdout, so capture stdout even on failure.
|
||||
output = execFileSync("ldd", [chromeBinaryPath], {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
// A timed-out/killed ldd leaves only a partial resolution table, which we
|
||||
// must NOT parse as authoritative (would report spurious missing libs).
|
||||
// Anything other than a clean non-zero exit with full stdout is
|
||||
// inconclusive.
|
||||
if (isKilledExecError(err)) {
|
||||
return { ok: true, missing: [], probeUnavailable: true };
|
||||
}
|
||||
const stdout = execErrorStdout(err);
|
||||
if (stdout == null) {
|
||||
// `ldd` not installed / not executable — we cannot conclude anything.
|
||||
return { ok: true, missing: [], probeUnavailable: true };
|
||||
}
|
||||
output = stdout;
|
||||
}
|
||||
return parseLddMissingLibs(output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `ldd` output into the set of unresolved libraries. Exported so the
|
||||
* parsing (the actual logic) is unit-tested without spawning a real process.
|
||||
*
|
||||
* A line like `libnss3.so => not found` means the loader can't find it.
|
||||
*/
|
||||
export function parseLddMissingLibs(lddOutput: string): SharedLibProbeResult {
|
||||
const missing: string[] = [];
|
||||
for (const rawLine of lddOutput.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
// Match the exact unresolved marker `=> not found` — NOT a bare "not found"
|
||||
// substring, which would false-positive on a resolved lib whose path
|
||||
// happens to contain that text (e.g. `libfoo.so => /opt/not found/...`).
|
||||
if (!/=>\s*not found\b/.test(line)) continue;
|
||||
const soname = line.split("=>")[0]?.trim();
|
||||
if (soname) missing.push(soname);
|
||||
}
|
||||
return { ok: missing.length === 0, missing, probeUnavailable: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an error message is the "Chrome couldn't load its shared libraries"
|
||||
* launch failure — the exact class `doctor` remediates. Matches both the raw
|
||||
* dynamic-linker text and Puppeteer's wrapper.
|
||||
*/
|
||||
export function isSharedLibLaunchError(message: string): boolean {
|
||||
return (
|
||||
/cannot open shared object file/i.test(message) ||
|
||||
/error while loading shared libraries/i.test(message) ||
|
||||
/lib[\w.+-]*\.so[\w.]*: cannot open/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a cryptic Chrome launch failure into actionable, per-distro guidance.
|
||||
* Returns the remediation block to show the user, or `undefined` when the error
|
||||
* isn't a shared-library/launch failure this module can help with.
|
||||
*/
|
||||
export function chromeLaunchRemediation(errorMessage: string): string | undefined {
|
||||
const isLaunchFailure =
|
||||
/Failed to launch the browser process/i.test(errorMessage) ||
|
||||
isSharedLibLaunchError(errorMessage);
|
||||
if (!isLaunchFailure) return undefined;
|
||||
if (process.platform !== "linux") return undefined;
|
||||
// On Linux ARM64 the render browser is system Chromium (see
|
||||
// ensureLinuxArmBrowser in manager.ts), so a launch failure there is almost
|
||||
// always a missing/mis-pathed chromium — NOT the headless-shell .so set. The
|
||||
// shared-lib install line would be wrong advice, so defer to that path's own
|
||||
// guidance instead of emitting it here.
|
||||
if (process.arch === "arm64") return undefined;
|
||||
|
||||
const distro = detectLinuxDistro();
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
`Chrome could not launch on ${distroLabel(distro)} — this is almost always missing system libraries (e.g. libnss3).`,
|
||||
);
|
||||
lines.push("Install the Chrome headless dependencies:");
|
||||
lines.push(` ${chromeDepsInstallCommand(distro.family)}`);
|
||||
lines.push("Then verify with: npx hyperframes doctor");
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
* `background-removal/manager.test.ts`) so we don't touch the real
|
||||
* `HOME` cache.
|
||||
*/
|
||||
import { join } from "node:path";
|
||||
import { join, sep } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Use `path.join` so the fake paths line up with whatever separator Node's
|
||||
@@ -30,7 +30,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
// `/fake/home/...` literals would fail on Windows because the set lookup
|
||||
// would never match the `\\`-joined real paths.
|
||||
const FAKE_HOME = join("/", "fake", "home");
|
||||
const CACHE_ROOT = join(FAKE_HOME, ".cache", "hyperframes");
|
||||
const HF_CACHE = join(FAKE_HOME, ".cache", "hyperframes", "chrome");
|
||||
const HF_LOCK = join(CACHE_ROOT, ".chrome.install.lock");
|
||||
const HF_RECLAIM_LOCK = join(CACHE_ROOT, ".chrome.install.reclaim.lock");
|
||||
const PUPPETEER_CACHE = join(FAKE_HOME, ".cache", "puppeteer", "chrome-headless-shell");
|
||||
const PUPPETEER_BINARY = join(
|
||||
PUPPETEER_CACHE,
|
||||
@@ -54,33 +57,82 @@ interface FsMockOptions {
|
||||
}
|
||||
|
||||
function installFsMocks({ existing, dirs }: FsMockOptions) {
|
||||
// Mutable, and returned, so tests can pre-seed a "lock already held" path or
|
||||
// assert the lock dir doesn't leak after ensureBrowser resolves.
|
||||
const paths = new Set(existing);
|
||||
const mtimes = new Map([...existing].map((p) => [p, 0]));
|
||||
vi.doMock("node:fs", () => ({
|
||||
existsSync: (p: string) => existing.has(p),
|
||||
existsSync: (p: string) => paths.has(p),
|
||||
readdirSync: (p: string) => {
|
||||
const entries = dirs?.[p];
|
||||
if (!entries) throw new Error(`ENOENT: readdirSync mock had no entry for ${p}`);
|
||||
return entries;
|
||||
},
|
||||
rmSync: () => {},
|
||||
mkdirSync: (p: string, opts?: { recursive?: boolean }) => {
|
||||
if (!opts?.recursive && paths.has(p)) {
|
||||
const err = new Error(`EEXIST: file already exists, mkdir '${p}'`);
|
||||
(err as NodeJS.ErrnoException).code = "EEXIST";
|
||||
throw err;
|
||||
}
|
||||
paths.add(p);
|
||||
mtimes.set(p, Date.now());
|
||||
},
|
||||
rmSync: (p: string) => {
|
||||
// Real rmSync({recursive:true}) removes the target AND everything under
|
||||
// it; the mock's flat path Set has no real tree structure, so simulate
|
||||
// that by also dropping any tracked path nested under `p`.
|
||||
for (const existingPath of [...paths]) {
|
||||
if (existingPath === p || existingPath.startsWith(p + sep)) {
|
||||
paths.delete(existingPath);
|
||||
mtimes.delete(existingPath);
|
||||
}
|
||||
}
|
||||
},
|
||||
statSync: (p: string) => {
|
||||
if (!paths.has(p)) {
|
||||
const err = new Error(`ENOENT: no such file or directory, stat '${p}'`);
|
||||
(err as NodeJS.ErrnoException).code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
return { mtimeMs: mtimes.get(p) ?? 0 };
|
||||
},
|
||||
}));
|
||||
vi.doMock("node:os", () => ({
|
||||
homedir: () => FAKE_HOME,
|
||||
platform: () => "linux",
|
||||
arch: () => "x64",
|
||||
}));
|
||||
return paths;
|
||||
}
|
||||
|
||||
function installPuppeteerBrowsersMock(
|
||||
opts: {
|
||||
installedInHfCache?: Array<{ browser: string; executablePath: string }>;
|
||||
installedInHfCache?: Array<{ browser: string; executablePath: string; path?: string }>;
|
||||
installedInHfCacheError?: Error;
|
||||
installResult?: { executablePath: string };
|
||||
installImpl?: () => Promise<{ executablePath: string }>;
|
||||
} = {},
|
||||
) {
|
||||
vi.doMock("@puppeteer/browsers", () => ({
|
||||
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
|
||||
detectBrowserPlatform: () => "linux",
|
||||
getInstalledBrowsers: vi.fn().mockResolvedValue(opts.installedInHfCache ?? []),
|
||||
install: vi.fn().mockResolvedValue(opts.installResult ?? { executablePath: HF_BINARY }),
|
||||
getInstalledBrowsers: opts.installedInHfCacheError
|
||||
? vi.fn().mockRejectedValue(opts.installedInHfCacheError)
|
||||
: vi.fn().mockResolvedValue(opts.installedInHfCache ?? []),
|
||||
install: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
opts.installImpl ?? (async () => opts.installResult ?? { executablePath: HF_BINARY }),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function installChildProcessMocks() {
|
||||
vi.doMock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => {
|
||||
throw new Error("not found");
|
||||
}),
|
||||
spawnSync: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -96,6 +148,7 @@ describe("findBrowser — cache resolution", () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
||||
Object.defineProperty(process, "arch", { value: "x64", configurable: true });
|
||||
delete process.env["HYPERFRAMES_BROWSER_PATH"];
|
||||
installChildProcessMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -104,6 +157,7 @@ describe("findBrowser — cache resolution", () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.doUnmock("node:fs");
|
||||
vi.doUnmock("node:os");
|
||||
vi.doUnmock("node:child_process");
|
||||
vi.doUnmock("@puppeteer/browsers");
|
||||
});
|
||||
|
||||
@@ -130,9 +184,15 @@ describe("findBrowser — cache resolution", () => {
|
||||
"chrome-headless-shell-linux64",
|
||||
"redownloaded-chrome-headless-shell",
|
||||
);
|
||||
installFsMocks({ existing: new Set([HF_CACHE]) });
|
||||
const staleInstallDir = join(HF_CACHE, "chrome-headless-shell", "linux-131.0.6778.85");
|
||||
// The stale install DIR is present (extraction got partway through, e.g. an
|
||||
// ABOUT/LICENSE-only extract) even though the exe itself is missing —
|
||||
// exercises the purge-before-redownload fix, not just the redownload path.
|
||||
const paths = installFsMocks({ existing: new Set([HF_CACHE, staleInstallDir]) });
|
||||
installPuppeteerBrowsersMock({
|
||||
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
|
||||
installedInHfCache: [
|
||||
{ browser: "chrome-headless-shell", executablePath: HF_BINARY, path: staleInstallDir },
|
||||
],
|
||||
installResult: { executablePath: redownloadedBinary },
|
||||
});
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
@@ -142,6 +202,149 @@ describe("findBrowser — cache resolution", () => {
|
||||
|
||||
expect(result).toEqual({ executablePath: redownloadedBinary, source: "download" });
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Cached binary missing"));
|
||||
// The stale directory must be gone before @puppeteer/browsers' install()
|
||||
// sees it — otherwise install() throws "folder exists but exe missing"
|
||||
// instead of re-extracting (the exact bug both feedback reports hit).
|
||||
expect(paths.has(staleInstallDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("ensureBrowser({force: true}) purges the whole cache before downloading, bypassing any cache/system shortcut", async () => {
|
||||
const staleInstallDir = join(HF_CACHE, "chrome-headless-shell", "linux-131.0.6778.85");
|
||||
const downloadedBinary = join(HF_CACHE, "chrome-headless-shell", "force-downloaded");
|
||||
// A HEALTHY cached binary AND system Chrome are both present — force must
|
||||
// ignore both shortcuts and always re-download, which is the whole point
|
||||
// of the flag (the reported bug: --force did nothing, a stale dir kept
|
||||
// winning over every retry).
|
||||
const paths = installFsMocks({
|
||||
existing: new Set([HF_CACHE, HF_BINARY, staleInstallDir, SYSTEM_CHROME]),
|
||||
});
|
||||
installPuppeteerBrowsersMock({
|
||||
installedInHfCache: [
|
||||
{ browser: "chrome-headless-shell", executablePath: HF_BINARY, path: staleInstallDir },
|
||||
],
|
||||
installResult: { executablePath: downloadedBinary },
|
||||
});
|
||||
|
||||
const { ensureBrowser } = await import("./manager.js");
|
||||
const result = await ensureBrowser({ force: true });
|
||||
|
||||
expect(result).toEqual({ executablePath: downloadedBinary, source: "download" });
|
||||
// clearBrowser() wipes prior contents; withInstallLock uses a sibling lock
|
||||
// outside CACHE_DIR, so assert the purge on what was actually INSIDE it,
|
||||
// not the directory's own existence.
|
||||
expect(paths.has(staleInstallDir)).toBe(false);
|
||||
expect(paths.has(HF_BINARY)).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes concurrent force downloads so one purge cannot delete another installer's lock", async () => {
|
||||
const downloadedBinary = join(HF_CACHE, "chrome-headless-shell", "force-downloaded");
|
||||
const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_CACHE, HF_BINARY]) });
|
||||
let activeInstalls = 0;
|
||||
let maxActiveInstalls = 0;
|
||||
installPuppeteerBrowsersMock({
|
||||
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
|
||||
installImpl: async () => {
|
||||
activeInstalls += 1;
|
||||
maxActiveInstalls = Math.max(maxActiveInstalls, activeInstalls);
|
||||
expect(paths.has(HF_LOCK)).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
activeInstalls -= 1;
|
||||
return { executablePath: downloadedBinary };
|
||||
},
|
||||
});
|
||||
|
||||
const { ensureBrowser } = await import("./manager.js");
|
||||
|
||||
await expect(
|
||||
Promise.all([ensureBrowser({ force: true }), ensureBrowser({ force: true })]),
|
||||
).resolves.toEqual([
|
||||
{ executablePath: downloadedBinary, source: "download" },
|
||||
{ executablePath: downloadedBinary, source: "download" },
|
||||
]);
|
||||
expect(maxActiveInstalls).toBe(1);
|
||||
expect(paths.has(HF_LOCK)).toBe(false);
|
||||
});
|
||||
|
||||
it("ensureBrowser does not leak the install lock directory after a successful download", async () => {
|
||||
// Regression: @puppeteer/browsers' install() has no concurrency guard —
|
||||
// two CLI invocations that both miss the cache AND system Chrome (the
|
||||
// reported scenario: two `hyperframes browser ensure` runs racing) hit
|
||||
// ensureBrowser's final download-of-last-resort at once, racing on the
|
||||
// same extract target. mkdirSync as an atomic mutex closes that race;
|
||||
// this asserts the lock is actually released afterward (a leaked lock
|
||||
// would permanently wedge every future render on this machine).
|
||||
const downloadedBinary = join(HF_CACHE, "chrome-headless-shell", "downloaded");
|
||||
// Cache dir exists but is empty (no manifest entries) — distinct from the
|
||||
// ENOTDIR "cache unreadable" case, which falls back to system instead.
|
||||
const paths = installFsMocks({ existing: new Set([HF_CACHE]) });
|
||||
installPuppeteerBrowsersMock({
|
||||
installedInHfCache: [],
|
||||
installResult: { executablePath: downloadedBinary },
|
||||
});
|
||||
|
||||
const { ensureBrowser } = await import("./manager.js");
|
||||
const result = await ensureBrowser();
|
||||
|
||||
expect(result).toEqual({ executablePath: downloadedBinary, source: "download" });
|
||||
expect(paths.has(HF_LOCK)).toBe(false);
|
||||
});
|
||||
|
||||
it("withInstallLock reclaims a lock held past the timeout instead of hanging forever", async () => {
|
||||
// A crashed/killed process could leave the lock directory behind
|
||||
// permanently. Reclaiming after a timeout (rather than hanging or
|
||||
// refusing forever) is the behavior that makes the lock safe to add at
|
||||
// all — otherwise one bad exit wedges every future render. Exercises
|
||||
// withInstallLock directly with tiny real timeouts (it takes an
|
||||
// injectable timeoutMs/pollMs for exactly this) rather than mocking
|
||||
// Date.now()/setTimeout through the full ensureBrowser call graph.
|
||||
const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_LOCK]) });
|
||||
|
||||
const { withInstallLock } = await import("./manager.js");
|
||||
const result = await withInstallLock(async () => "done", 10, 5);
|
||||
|
||||
expect(result).toBe("done");
|
||||
expect(paths.has(HF_LOCK)).toBe(false);
|
||||
});
|
||||
|
||||
it("withInstallLock does not reclaim another waiter's fresh lock after this waiter timed out", async () => {
|
||||
// Regression guard for the timeout-reclaim race: if multiple waiters cross
|
||||
// the stale-lock deadline together, waiter A can reclaim the stale lock and
|
||||
// acquire a fresh one. Waiter B's old deadline is still expired, but it must
|
||||
// not delete A's fresh lock.
|
||||
const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_LOCK]) });
|
||||
|
||||
const { withInstallLock } = await import("./manager.js");
|
||||
const first = withInstallLock(
|
||||
async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
return "first";
|
||||
},
|
||||
10,
|
||||
5,
|
||||
);
|
||||
const second = withInstallLock(async () => "second", 10, 5);
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual(["first", "second"]);
|
||||
expect(paths.has(HF_LOCK)).toBe(false);
|
||||
expect(paths.has(HF_RECLAIM_LOCK)).toBe(false);
|
||||
});
|
||||
|
||||
it("warns and falls through when the hyperframes cache cannot be read", async () => {
|
||||
installFsMocks({ existing: new Set([HF_CACHE, SYSTEM_CHROME]) });
|
||||
installPuppeteerBrowsersMock({
|
||||
installedInHfCacheError: Object.assign(new Error("ENOTDIR: not a directory"), {
|
||||
code: "ENOTDIR",
|
||||
}),
|
||||
});
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
|
||||
_resetSystemFallbackWarnForTests();
|
||||
const result = await findBrowser();
|
||||
|
||||
expect(result).toEqual({ executablePath: SYSTEM_CHROME, source: "system" });
|
||||
expect(warnSpy.mock.calls[0]?.[0]).toContain("Browser cache read failed (ENOTDIR)");
|
||||
expect(warnSpy.mock.calls[0]?.[0]).toContain("Falling back to system Chrome");
|
||||
});
|
||||
|
||||
it("falls back to the puppeteer-managed cache when hyperframes cache is empty", async () => {
|
||||
@@ -298,3 +501,69 @@ describe("findBrowser — cache resolution", () => {
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCorruptArchiveError", () => {
|
||||
it("matches truncated / corrupt archive extraction failures", async () => {
|
||||
const { isCorruptArchiveError } = await import("./manager.js");
|
||||
for (const msg of [
|
||||
"invalid end-of-central-directory record",
|
||||
"end of central directory record signature not found",
|
||||
"invalid or corrupt zip file",
|
||||
"File is not a zip file",
|
||||
"unexpected end of file",
|
||||
"the archive is corrupted",
|
||||
]) {
|
||||
expect(isCorruptArchiveError(new Error(msg))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not match network or unrelated errors", async () => {
|
||||
const { isCorruptArchiveError } = await import("./manager.js");
|
||||
for (const msg of ["ECONNRESET", "socket hang up", "ENOENT: no such file", "boom"]) {
|
||||
expect(isCorruptArchiveError(new Error(msg))).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("installWithCorruptArchiveRecovery", () => {
|
||||
it("clears the cache and re-downloads once on a corrupt archive, then succeeds", async () => {
|
||||
const { installWithCorruptArchiveRecovery } = await import("./manager.js");
|
||||
const runInstall = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("invalid end-of-central-directory record"))
|
||||
.mockResolvedValueOnce({ executablePath: "/ok" });
|
||||
const clearCache = vi.fn();
|
||||
const onRecover = vi.fn();
|
||||
|
||||
const result = await installWithCorruptArchiveRecovery(runInstall, clearCache, onRecover);
|
||||
|
||||
expect(result).toEqual({ executablePath: "/ok" });
|
||||
expect(runInstall).toHaveBeenCalledTimes(2);
|
||||
expect(clearCache).toHaveBeenCalledTimes(1);
|
||||
expect(onRecover).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("propagates a non-corruption error without clearing the cache", async () => {
|
||||
const { installWithCorruptArchiveRecovery } = await import("./manager.js");
|
||||
const runInstall = vi.fn().mockRejectedValue(new Error("ECONNRESET"));
|
||||
const clearCache = vi.fn();
|
||||
|
||||
await expect(installWithCorruptArchiveRecovery(runInstall, clearCache)).rejects.toThrow(
|
||||
"ECONNRESET",
|
||||
);
|
||||
expect(runInstall).toHaveBeenCalledTimes(1);
|
||||
expect(clearCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not retry forever: a second corruption propagates", async () => {
|
||||
const { installWithCorruptArchiveRecovery } = await import("./manager.js");
|
||||
const runInstall = vi.fn().mockRejectedValue(new Error("end of central directory not found"));
|
||||
const clearCache = vi.fn();
|
||||
|
||||
await expect(installWithCorruptArchiveRecovery(runInstall, clearCache)).rejects.toThrow(
|
||||
"end of central directory",
|
||||
);
|
||||
expect(runInstall).toHaveBeenCalledTimes(2);
|
||||
expect(clearCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { existsSync, readdirSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
|
||||
type PuppeteerBrowsers = typeof import("@puppeteer/browsers");
|
||||
|
||||
@@ -11,7 +12,7 @@ async function loadPuppeteerBrowsers(): Promise<PuppeteerBrowsers> {
|
||||
try {
|
||||
return await import("@puppeteer/browsers");
|
||||
} catch (err) {
|
||||
const cause = err instanceof Error ? err.message : String(err);
|
||||
const cause = normalizeErrorMessage(err);
|
||||
throw new Error(
|
||||
`Failed to load @puppeteer/browsers: ${cause}\n` +
|
||||
`Fix: run \`npm install\` or \`bun install\` to restore missing packages, then retry.`,
|
||||
@@ -20,6 +21,7 @@ async function loadPuppeteerBrowsers(): Promise<PuppeteerBrowsers> {
|
||||
}
|
||||
|
||||
const CHROME_VERSION = "131.0.6778.85";
|
||||
const CACHE_ROOT_DIR = join(homedir(), ".cache", "hyperframes");
|
||||
const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome");
|
||||
// Puppeteer's managed cache — where `@puppeteer/browsers install
|
||||
// chrome-headless-shell` (and `puppeteer install`) drop binaries. The engine's
|
||||
@@ -27,6 +29,98 @@ const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome");
|
||||
// too or it silently picks system Chrome over a perfectly good headless-shell.
|
||||
const PUPPETEER_CACHE_DIR = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell");
|
||||
|
||||
// `@puppeteer/browsers`' install() has no concurrency guard of its own — two
|
||||
// CLI invocations that both miss the cache at the same time both extract into
|
||||
// the same target directory simultaneously. A killed/interrupted extraction
|
||||
// from that race can leave a binary that merely *exists* (so doctor/lint/
|
||||
// validate all report healthy) while missing bits a clean install sets (e.g.
|
||||
// macOS Gatekeeper/quarantine + GPU/Metal entitlements) — reported as headless
|
||||
// GPU frame capture silently returning all-black frames despite --browser-gpu
|
||||
// auto/hardware, invisible until someone inspects the actual pixels.
|
||||
//
|
||||
// mkdirSync is atomic (EEXIST if another process already holds it), so it
|
||||
// doubles as a zero-dependency cross-process mutex — no lockfile library needed.
|
||||
const INSTALL_LOCK_DIR = join(CACHE_ROOT_DIR, ".chrome.install.lock");
|
||||
const INSTALL_RECLAIM_LOCK_DIR = join(CACHE_ROOT_DIR, ".chrome.install.reclaim.lock");
|
||||
const INSTALL_LOCK_TIMEOUT_MS = 120_000; // generous: a real download+extract can take a while
|
||||
const INSTALL_LOCK_POLL_MS = 200;
|
||||
|
||||
function isErrno(err: unknown, code: string): boolean {
|
||||
return (err as NodeJS.ErrnoException).code === code;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function tryAcquireDirLock(lockDir: string): boolean {
|
||||
try {
|
||||
// recursive:false is load-bearing: it's what makes this throw EEXIST
|
||||
// (and therefore act as a mutex) instead of silently no-op'ing like
|
||||
// `mkdir -p` when the lock dir already exists.
|
||||
mkdirSync(lockDir, { recursive: false });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (!isErrno(err, "EEXIST")) throw err;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function reclaimStaleInstallLock(timeoutMs: number): void {
|
||||
if (!tryAcquireDirLock(INSTALL_RECLAIM_LOCK_DIR)) return;
|
||||
try {
|
||||
const mtimeMs = statSync(INSTALL_LOCK_DIR).mtimeMs;
|
||||
if (Date.now() - mtimeMs > timeoutMs) {
|
||||
rmSync(INSTALL_LOCK_DIR, { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isErrno(err, "ENOENT")) throw err;
|
||||
} finally {
|
||||
rmSync(INSTALL_RECLAIM_LOCK_DIR, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// timeoutMs/pollMs are parameters (not just the module constants) so tests can
|
||||
// exercise the reclaim-on-timeout branch with real but tiny waits instead of
|
||||
// mocking Date.now()/setTimeout through the full ensureBrowser call graph.
|
||||
export async function withInstallLock<T>(
|
||||
fn: () => Promise<T>,
|
||||
timeoutMs = INSTALL_LOCK_TIMEOUT_MS,
|
||||
pollMs = INSTALL_LOCK_POLL_MS,
|
||||
): Promise<T> {
|
||||
// recursive:false below needs the parent to already exist (unlike `mkdir -p`).
|
||||
// Keep lock dirs outside CACHE_DIR so force-clearing the Chrome cache cannot
|
||||
// delete another installer's in-flight lock.
|
||||
if (!existsSync(CACHE_ROOT_DIR)) mkdirSync(CACHE_ROOT_DIR, { recursive: true });
|
||||
let deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
if (existsSync(INSTALL_RECLAIM_LOCK_DIR)) {
|
||||
await sleep(pollMs);
|
||||
continue;
|
||||
}
|
||||
if (tryAcquireDirLock(INSTALL_LOCK_DIR)) {
|
||||
rmSync(INSTALL_RECLAIM_LOCK_DIR, { recursive: true, force: true });
|
||||
break;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
// The reclaim gate matters when multiple waiters cross the timeout at
|
||||
// once: without it, waiter A can delete the stale lock and acquire a
|
||||
// fresh one, then waiter B (whose old deadline also expired) can delete
|
||||
// A's fresh lock. The gate serializes reclaimers, and the mtime re-check
|
||||
// after the gate prevents deleting a fresh lock another waiter just won.
|
||||
reclaimStaleInstallLock(timeoutMs);
|
||||
deadline = Date.now() + timeoutMs;
|
||||
continue;
|
||||
}
|
||||
await sleep(pollMs);
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
rmSync(INSTALL_LOCK_DIR, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export type BrowserSource = "env" | "cache" | "system" | "download";
|
||||
|
||||
export interface BrowserResult {
|
||||
@@ -36,11 +130,31 @@ export interface BrowserResult {
|
||||
|
||||
export interface EnsureBrowserOptions {
|
||||
onProgress?: (downloadedBytes: number, totalBytes: number) => void;
|
||||
// Purge any cached HF-managed download before resolving, so a stale or
|
||||
// partially-extracted install can't make the retry look like a no-op.
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
interface CacheLookupResult {
|
||||
result?: BrowserResult;
|
||||
staleHyperframesCachePath?: string;
|
||||
// Root install-folder path for the stale entry (InstalledBrowser#path), NOT
|
||||
// the missing executablePath above — this is what actually needs deleting.
|
||||
staleInstallPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one browser version's install directory (not the whole CACHE_DIR).
|
||||
* @puppeteer/browsers' install() treats an existing-but-incomplete directory
|
||||
* as already installed and throws "folder exists but executable is missing"
|
||||
* rather than re-extracting — so an extraction interrupted by a Windows AV
|
||||
* lock, a sleep/wake cycle, or ctrl-C (left with only alphabetically-early
|
||||
* files like ABOUT/LICENSE, no exe) wedges every subsequent ensure/render
|
||||
* with the same error until someone manually deletes the directory. Purging
|
||||
* it first makes the retry actually retry.
|
||||
*/
|
||||
function purgeStaleInstall(installPath: string): void {
|
||||
rmSync(installPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// --- Internal helpers -------------------------------------------------------
|
||||
@@ -104,13 +218,27 @@ async function findFromCache(): Promise<CacheLookupResult> {
|
||||
// no puppeteer-cache binary exists.
|
||||
if (existsSync(CACHE_DIR)) {
|
||||
const { Browser, getInstalledBrowsers } = await loadPuppeteerBrowsers();
|
||||
const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
|
||||
// A corrupt cache (stub file where a browser dir is expected, malformed
|
||||
// metadata) makes getInstalledBrowsers throw. Treat that as "no cached
|
||||
// browser" so resolution falls through to system/download instead of
|
||||
// crashing every caller.
|
||||
let installed: Awaited<ReturnType<typeof getInstalledBrowsers>>;
|
||||
try {
|
||||
installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
const suffix = code ? ` (${code})` : "";
|
||||
console.warn(
|
||||
`[hyperframes] Browser cache read failed${suffix}: ${normalizeErrorMessage(err)}. Falling back to system Chrome or a fresh download.`,
|
||||
);
|
||||
installed = [];
|
||||
}
|
||||
const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
|
||||
if (match && existsSync(match.executablePath)) {
|
||||
return { result: { executablePath: match.executablePath, source: "cache" } };
|
||||
}
|
||||
if (match) {
|
||||
return { staleHyperframesCachePath: match.executablePath };
|
||||
return { staleHyperframesCachePath: match.executablePath, staleInstallPath: match.path };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,9 +394,12 @@ export async function findBrowser(): Promise<BrowserResult | undefined> {
|
||||
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
|
||||
);
|
||||
try {
|
||||
return await downloadBrowser();
|
||||
return await withInstallLock(async () => {
|
||||
if (fromCache.staleInstallPath) purgeStaleInstall(fromCache.staleInstallPath);
|
||||
return downloadBrowser();
|
||||
});
|
||||
} catch (err) {
|
||||
const cause = err instanceof Error ? err.message : String(err);
|
||||
const cause = normalizeErrorMessage(err);
|
||||
throw new Error(
|
||||
`Cached Chrome binary was missing at ${fromCache.staleHyperframesCachePath}, and re-download failed: ${cause}\n` +
|
||||
`Run \`hyperframes browser ensure --force\` to re-download.`,
|
||||
@@ -340,22 +471,88 @@ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<Bro
|
||||
const fromEnv = findFromEnv();
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const fromCache = await findFromCache();
|
||||
if (fromCache.result) return fromCache.result;
|
||||
if (fromCache.staleHyperframesCachePath) {
|
||||
console.warn(
|
||||
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
|
||||
);
|
||||
if (!options?.force) {
|
||||
const fromCache = await findFromCache();
|
||||
if (fromCache.result) return fromCache.result;
|
||||
if (fromCache.staleHyperframesCachePath) {
|
||||
console.warn(
|
||||
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
|
||||
);
|
||||
return withInstallLock(async () => {
|
||||
if (fromCache.staleInstallPath) purgeStaleInstall(fromCache.staleInstallPath);
|
||||
return downloadBrowser(options);
|
||||
});
|
||||
}
|
||||
|
||||
const fromSystem = findFromSystem();
|
||||
if (fromSystem) {
|
||||
warnSystemFallbackOnce(fromSystem.executablePath);
|
||||
return fromSystem;
|
||||
}
|
||||
}
|
||||
|
||||
return withInstallLock(async () => {
|
||||
if (options?.force) {
|
||||
// `--force` means "always get a fresh managed download" — purging the
|
||||
// whole HF-managed cache after acquiring the install lock keeps two
|
||||
// concurrent force retries from deleting each other's in-flight lock or
|
||||
// partially extracted install.
|
||||
clearBrowser();
|
||||
}
|
||||
|
||||
// Re-check after acquiring the lock: a concurrent invocation may have
|
||||
// finished installing while we were waiting, in which case reuse its
|
||||
// result instead of downloading and extracting a second time. Skipped
|
||||
// under --force, which already purged and always wants a fresh download.
|
||||
if (!options?.force) {
|
||||
const afterLock = await findFromCache();
|
||||
if (afterLock.result) return afterLock.result;
|
||||
if (afterLock.staleInstallPath) purgeStaleInstall(afterLock.staleInstallPath);
|
||||
}
|
||||
return downloadBrowser(options);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const fromSystem = findFromSystem();
|
||||
if (fromSystem) {
|
||||
warnSystemFallbackOnce(fromSystem.executablePath);
|
||||
return fromSystem;
|
||||
}
|
||||
/**
|
||||
* True when `err` is a corrupt/truncated-archive extraction failure, as opposed
|
||||
* to a network error or a genuine platform problem. A partially-downloaded or
|
||||
* interrupted browser archive left in the cache makes `install()`'s extraction
|
||||
* throw "invalid end-of-central-directory" (a zip whose central directory is
|
||||
* missing/truncated). Left unhandled, that hard-blocks every render on the box
|
||||
* until the user manually clears the cache — so we detect it and re-download.
|
||||
*/
|
||||
export function isCorruptArchiveError(err: unknown): boolean {
|
||||
const msg = normalizeErrorMessage(err).toLowerCase();
|
||||
return (
|
||||
msg.includes("end of central directory") ||
|
||||
msg.includes("end-of-central-directory") ||
|
||||
msg.includes("invalid or corrupt") ||
|
||||
msg.includes("corrupt zip") ||
|
||||
msg.includes("not a zip") ||
|
||||
msg.includes("unexpected end of") ||
|
||||
msg.includes("corrupted")
|
||||
);
|
||||
}
|
||||
|
||||
return downloadBrowser(options);
|
||||
/**
|
||||
* Run a browser install; if it fails because the cached archive is corrupt,
|
||||
* clear the cache (dropping the bad archive) and retry the download exactly
|
||||
* once. Non-corruption errors propagate unchanged, and a second corruption
|
||||
* propagates too (no infinite retry).
|
||||
*/
|
||||
export async function installWithCorruptArchiveRecovery<T>(
|
||||
runInstall: () => Promise<T>,
|
||||
clearCache: () => void,
|
||||
onRecover?: (err: unknown) => void,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await runInstall();
|
||||
} catch (err) {
|
||||
if (!isCorruptArchiveError(err)) throw err;
|
||||
onRecover?.(err);
|
||||
clearCache();
|
||||
return await runInstall();
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
|
||||
@@ -370,13 +567,26 @@ async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserR
|
||||
throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`);
|
||||
}
|
||||
|
||||
const installed = await install({
|
||||
cacheDir: CACHE_DIR,
|
||||
browser: Browser.CHROMEHEADLESSSHELL,
|
||||
buildId: CHROME_VERSION,
|
||||
platform,
|
||||
downloadProgressCallback: options?.onProgress,
|
||||
});
|
||||
const runInstall = () =>
|
||||
install({
|
||||
cacheDir: CACHE_DIR,
|
||||
browser: Browser.CHROMEHEADLESSSHELL,
|
||||
buildId: CHROME_VERSION,
|
||||
platform,
|
||||
downloadProgressCallback: options?.onProgress,
|
||||
});
|
||||
|
||||
const installed = await installWithCorruptArchiveRecovery(
|
||||
runInstall,
|
||||
() => {
|
||||
rmSync(CACHE_DIR, { recursive: true, force: true });
|
||||
mkdirSync(CACHE_DIR, { recursive: true });
|
||||
},
|
||||
(err) =>
|
||||
console.warn(
|
||||
`[hyperframes] Cached browser archive was corrupt (${normalizeErrorMessage(err)}); clearing the cache and re-downloading.`,
|
||||
),
|
||||
);
|
||||
|
||||
return { executablePath: installed.executablePath, source: "download" };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseToolVersion, runEnvironmentChecks } from "./preflight.js";
|
||||
import * as manager from "./manager.js";
|
||||
import * as linuxDeps from "./linuxDeps.js";
|
||||
|
||||
describe("runEnvironmentChecks", () => {
|
||||
const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||
@@ -67,6 +69,27 @@ describe("runEnvironmentChecks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports Chrome as not found (no throw) when browser discovery throws on a corrupt cache", async () => {
|
||||
const spy = vi.spyOn(manager, "findBrowser").mockRejectedValue(
|
||||
Object.assign(new Error("ENOTDIR: not a directory, scandir 'chrome-headless-shell'"), {
|
||||
code: "ENOTDIR",
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await runEnvironmentChecks({ includeBrowser: true });
|
||||
|
||||
expect(result.outcomes.find((outcome) => outcome.name === "Chrome")).toMatchObject({
|
||||
ok: false,
|
||||
title: "Chrome not found",
|
||||
hint: "Run: npx hyperframes browser ensure",
|
||||
});
|
||||
expect(result.browser).toBeUndefined();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports an explicit missing browser path before render starts", async () => {
|
||||
const result = await runEnvironmentChecks({
|
||||
includeBrowser: true,
|
||||
@@ -80,6 +103,83 @@ describe("runEnvironmentChecks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runEnvironmentChecks — Chrome shared libraries (Linux/WSL)", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("downgrades a found Chrome to a render-blocking error when libs are missing", async () => {
|
||||
vi.spyOn(manager, "findBrowser").mockResolvedValue({
|
||||
executablePath: "/root/.cache/hyperframes/chrome-headless-shell",
|
||||
source: "cache",
|
||||
});
|
||||
vi.spyOn(linuxDeps, "probeChromeSharedLibs").mockReturnValue({
|
||||
ok: false,
|
||||
missing: ["libnss3.so", "libatk-1.0.so.0"],
|
||||
probeUnavailable: false,
|
||||
});
|
||||
vi.spyOn(linuxDeps, "detectLinuxDistro").mockReturnValue({
|
||||
family: "debian",
|
||||
id: "ubuntu",
|
||||
prettyName: "Ubuntu 22.04.3 LTS",
|
||||
isWsl: true,
|
||||
});
|
||||
|
||||
const result = await runEnvironmentChecks({ includeBrowser: true });
|
||||
const chrome = result.outcomes.find((o) => o.name === "Chrome");
|
||||
|
||||
expect(chrome).toMatchObject({
|
||||
ok: false,
|
||||
level: "error",
|
||||
title: "Chrome cannot launch (missing system libraries)",
|
||||
});
|
||||
expect(chrome?.detail).toContain("WSL");
|
||||
expect(chrome?.detail).toContain("libnss3.so");
|
||||
expect(chrome?.hint).toContain("apt-get install -y");
|
||||
expect(chrome?.hint).toContain("libnss3");
|
||||
// A lib-broken Chrome must NOT be handed to the render pipeline as usable.
|
||||
expect(result.browser).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps Chrome ok when the shared-lib probe passes", async () => {
|
||||
vi.spyOn(manager, "findBrowser").mockResolvedValue({
|
||||
executablePath: "/usr/bin/chromium",
|
||||
source: "system",
|
||||
});
|
||||
vi.spyOn(linuxDeps, "probeChromeSharedLibs").mockReturnValue({
|
||||
ok: true,
|
||||
missing: [],
|
||||
probeUnavailable: false,
|
||||
});
|
||||
|
||||
const result = await runEnvironmentChecks({ includeBrowser: true });
|
||||
expect(result.outcomes.find((o) => o.name === "Chrome")).toMatchObject({ ok: true });
|
||||
expect(result.browser?.executablePath).toBe("/usr/bin/chromium");
|
||||
});
|
||||
|
||||
it("keeps Chrome ok when the probe is inconclusive (no ldd)", async () => {
|
||||
vi.spyOn(manager, "findBrowser").mockResolvedValue({
|
||||
executablePath: "/usr/bin/chromium",
|
||||
source: "system",
|
||||
});
|
||||
vi.spyOn(linuxDeps, "probeChromeSharedLibs").mockReturnValue({
|
||||
ok: false,
|
||||
missing: [],
|
||||
probeUnavailable: true,
|
||||
});
|
||||
|
||||
const result = await runEnvironmentChecks({ includeBrowser: true });
|
||||
expect(result.outcomes.find((o) => o.name === "Chrome")).toMatchObject({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseToolVersion", () => {
|
||||
it("extracts ffprobe versions with Windows build suffixes", () => {
|
||||
expect(parseToolVersion("ffprobe version 7.1.1-essentials_build-www.gyan.dev Copyright")).toBe(
|
||||
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
findFFprobe,
|
||||
getFFmpegInstallHint,
|
||||
} from "./ffmpeg.js";
|
||||
import {
|
||||
chromeDepsInstallCommand,
|
||||
detectLinuxDistro,
|
||||
distroLabel,
|
||||
probeChromeSharedLibs,
|
||||
} from "./linuxDeps.js";
|
||||
import { getFreeDiskMb } from "../telemetry/system.js";
|
||||
|
||||
export type EnvironmentCheckLevel = "ok" | "warn" | "error";
|
||||
@@ -118,16 +124,45 @@ function checkFFprobe(): EnvironmentCheckOutcome {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A Chrome binary can exist on disk yet be unlaunchable because its system
|
||||
* shared libraries (libnss3, libatk, ...) aren't installed — the dominant WSL
|
||||
* first-render failure. When that's the case, downgrade the "found" outcome to
|
||||
* a render-blocking error carrying the exact per-distro install command, so the
|
||||
* user hits it in `doctor`/pre-flight instead of a cryptic
|
||||
* `Failed to launch the browser process` mid-render. No-op off Linux and when
|
||||
* `ldd` can't run (probe inconclusive).
|
||||
*/
|
||||
function chromeSharedLibOutcome(
|
||||
executablePath: string,
|
||||
found: EnvironmentCheckOutcome,
|
||||
): EnvironmentCheckOutcome {
|
||||
if (process.platform !== "linux") return found;
|
||||
const probe = probeChromeSharedLibs(executablePath);
|
||||
if (probe.probeUnavailable || probe.ok) return found;
|
||||
|
||||
const distro = detectLinuxDistro();
|
||||
return {
|
||||
name: "Chrome",
|
||||
ok: false,
|
||||
level: "error",
|
||||
title: "Chrome cannot launch (missing system libraries)",
|
||||
detail: `Chrome at ${executablePath} is missing shared libraries on ${distroLabel(distro)}: ${probe.missing.join(", ")}`,
|
||||
hint: chromeDepsInstallCommand(distro.family),
|
||||
path: executablePath,
|
||||
};
|
||||
}
|
||||
|
||||
async function checkChrome(browserPath?: string): Promise<EnvironmentCheckOutcome> {
|
||||
if (browserPath) {
|
||||
if (existsSync(browserPath)) {
|
||||
return {
|
||||
return chromeSharedLibOutcome(browserPath, {
|
||||
name: "Chrome",
|
||||
ok: true,
|
||||
level: "ok",
|
||||
detail: `explicit: ${browserPath}`,
|
||||
path: browserPath,
|
||||
};
|
||||
});
|
||||
}
|
||||
return {
|
||||
name: "Chrome",
|
||||
@@ -139,15 +174,25 @@ async function checkChrome(browserPath?: string): Promise<EnvironmentCheckOutcom
|
||||
};
|
||||
}
|
||||
|
||||
const info = await findBrowser();
|
||||
// A corrupt/partial browser cache (stub files where a version dir is
|
||||
// expected, missing executable, malformed metadata) makes findBrowser throw.
|
||||
// That is the exact condition this check exists to report, so treat any
|
||||
// failure as "Chrome not found" rather than letting it crash the caller
|
||||
// (notably `doctor`, which is documented to exit 0 even when checks fail).
|
||||
let info: Awaited<ReturnType<typeof findBrowser>>;
|
||||
try {
|
||||
info = await findBrowser();
|
||||
} catch {
|
||||
info = undefined;
|
||||
}
|
||||
if (info) {
|
||||
return {
|
||||
return chromeSharedLibOutcome(info.executablePath, {
|
||||
name: "Chrome",
|
||||
ok: true,
|
||||
level: "ok",
|
||||
detail: `${info.source}: ${info.executablePath}`,
|
||||
path: info.executablePath,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -511,6 +511,8 @@ export function generateAssetDescriptions(
|
||||
heading?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
sourceWidth?: number;
|
||||
sourceHeight?: number;
|
||||
}>;
|
||||
for (const v of manifest) {
|
||||
if (!v.localPath) continue; // only describe clips that actually downloaded
|
||||
@@ -518,7 +520,9 @@ export function generateAssetDescriptions(
|
||||
if (!base) continue;
|
||||
const desc =
|
||||
(v.caption || v.heading || "").trim().replace(/\s+/g, " ").slice(0, 140) || "motion clip";
|
||||
const dims = v.width && v.height ? `, ~${v.width}×${v.height}` : "";
|
||||
const dimW = v.sourceWidth || v.width;
|
||||
const dimH = v.sourceHeight || v.height;
|
||||
const dims = dimW && dimH ? `, ~${dimW}×${dimH}` : "";
|
||||
videoLines.push(`${base} — [video] ${desc}${dims}`);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -21,8 +21,12 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
||||
function rgbToHex(color) {
|
||||
if (!color) return "";
|
||||
if (color.startsWith('#')) return color.toUpperCase();
|
||||
var m = color.match(/rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/);
|
||||
// capture optional alpha (group 4), allowing both comma and modern slash (rgb r g b / a) syntax.
|
||||
var m = color.match(/rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)(?:\\s*[,/]\\s*([\\d.]+))?/);
|
||||
if (!m) return color;
|
||||
// fully-transparent fill (rgba(...,0)) → sentinel, NOT #000000 — otherwise a transparent
|
||||
// chip/tab/stat ground reads as solid black on a light-ground site.
|
||||
if (m[4] !== undefined && parseFloat(m[4]) === 0) return "transparent";
|
||||
return '#' + ((1<<24) + (parseInt(m[1])<<16) + (parseInt(m[2])<<8) + parseInt(m[3])).toString(16).slice(1).toUpperCase();
|
||||
}
|
||||
|
||||
@@ -30,8 +34,15 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
||||
return f.split(",")[0].replace(/['"]/g, "").trim();
|
||||
}
|
||||
|
||||
// keep only gradient background-images (drop url() sprites + "none"); gradients are a core
|
||||
// brand signal (Stripe/ElevenLabs/Snowflake mesh washes) that a flat background-color misses.
|
||||
function gradientOf(v) {
|
||||
return v && v.indexOf("gradient") >= 0 ? v.trim() : "";
|
||||
}
|
||||
|
||||
function getStyles(el) {
|
||||
var s = getComputedStyle(el);
|
||||
var bf = s.backdropFilter || s.webkitBackdropFilter || "";
|
||||
return {
|
||||
fontFamily: cleanFont(s.fontFamily),
|
||||
fontSize: s.fontSize,
|
||||
@@ -40,6 +51,8 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
||||
letterSpacing: s.letterSpacing,
|
||||
color: rgbToHex(s.color),
|
||||
background: rgbToHex(s.backgroundColor),
|
||||
backgroundImage: gradientOf(s.backgroundImage),
|
||||
backdropFilter: bf === "none" ? "" : bf,
|
||||
padding: s.padding,
|
||||
borderRadius: s.borderRadius,
|
||||
border: s.border,
|
||||
@@ -108,23 +121,37 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
||||
}
|
||||
|
||||
// ── 2. Buttons ──
|
||||
// a page's primary CTA is very often a FILLED pill in the nav/header ("Sign up", "Start for free").
|
||||
// Old code dropped everything under <nav>, losing that CTA; keep nav elements that carry a solid
|
||||
// fill (a real button), still dropping plain nav text links.
|
||||
var isFilledEl = (el) => {
|
||||
var cs = getComputedStyle(el);
|
||||
var bg = cs.backgroundColor;
|
||||
var solid = !!bg && bg !== "transparent" && !/rgba?\\([^)]*,\\s*0\\s*\\)/.test(bg);
|
||||
// a gradient-filled CTA (e.g. Snowflake's blue pill = background: var(--ui-background-03)) has a
|
||||
// transparent background-COLOR but a gradient background-IMAGE — count it as filled too.
|
||||
return solid || (cs.backgroundImage || "").indexOf("gradient") >= 0;
|
||||
};
|
||||
var buttonEls = Array.from(document.querySelectorAll(
|
||||
'button, a[class*="btn"], a[class*="button"], a[role="button"], ' +
|
||||
'[class*="btn-"], [class*="button-"], [class*="cta"]'
|
||||
)).filter(function(el) {
|
||||
return isVisible(el) && !el.closest('nav, [role="navigation"]');
|
||||
}).slice(0, 10);
|
||||
if (!isVisible(el)) return false;
|
||||
return el.closest('nav, [role="navigation"]') ? isFilledEl(el) : true;
|
||||
}).slice(0, 16);
|
||||
|
||||
var buttonMap = {};
|
||||
for (var bi = 0; bi < buttonEls.length; bi++) {
|
||||
var bs = getStyles(buttonEls[bi]);
|
||||
// Deduplicate by visual appearance
|
||||
var bKey = bs.background + "|" + bs.borderRadius + "|" + bs.border;
|
||||
// Deduplicate by visual appearance (gradient fill kept distinct so a gradient CTA survives)
|
||||
var bKey = bs.background + "|" + bs.backgroundImage + "|" + bs.borderRadius + "|" + bs.border;
|
||||
if (!buttonMap[bKey]) {
|
||||
var btnText = (buttonEls[bi].textContent || "").trim().slice(0, 40);
|
||||
buttonMap[bKey] = {
|
||||
label: btnText || "button",
|
||||
background: bs.background,
|
||||
backgroundImage: bs.backgroundImage,
|
||||
backdropFilter: bs.backdropFilter,
|
||||
color: bs.color,
|
||||
padding: bs.padding,
|
||||
borderRadius: bs.borderRadius,
|
||||
@@ -136,7 +163,7 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
||||
};
|
||||
}
|
||||
}
|
||||
var buttons = Object.values(buttonMap).slice(0, 4);
|
||||
var buttons = Object.values(buttonMap).slice(0, 6);
|
||||
|
||||
// ── 3. Cards / containers ──
|
||||
var cardEls = Array.from(document.querySelectorAll(
|
||||
@@ -151,11 +178,14 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
||||
var cardMap = {};
|
||||
for (var ci = 0; ci < cardEls.length; ci++) {
|
||||
var cs = getStyles(cardEls[ci]);
|
||||
var cKey = cs.background + "|" + cs.borderRadius + "|" + cs.border;
|
||||
// gradient fill + glass blur kept in the key so a gradient/frosted card is a distinct variant
|
||||
var cKey = cs.background + "|" + cs.backgroundImage + "|" + cs.backdropFilter + "|" + cs.borderRadius + "|" + cs.border;
|
||||
if (!cardMap[cKey]) {
|
||||
cardMap[cKey] = {
|
||||
label: "card",
|
||||
background: cs.background,
|
||||
backgroundImage: cs.backgroundImage,
|
||||
backdropFilter: cs.backdropFilter,
|
||||
color: cs.color,
|
||||
padding: cs.padding,
|
||||
borderRadius: cs.borderRadius,
|
||||
@@ -177,6 +207,8 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
||||
nav = {
|
||||
label: "navigation",
|
||||
background: ns.background,
|
||||
backgroundImage: ns.backgroundImage,
|
||||
backdropFilter: ns.backdropFilter,
|
||||
color: ns.color,
|
||||
padding: ns.padding,
|
||||
borderRadius: ns.borderRadius,
|
||||
@@ -188,6 +220,100 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
||||
};
|
||||
}
|
||||
|
||||
// ── 4b. Chips / pills / badges / tags ──
|
||||
// selector by class-substring, PLUS a shape fallback (small + fully-rounded + short text) so
|
||||
// hashed/utility class names (Tailwind, CSS-modules) still get caught.
|
||||
var chipEls = Array.from(document.querySelectorAll(
|
||||
'[class*="pill"], [class*="Pill"], [class*="badge"], [class*="Badge"], ' +
|
||||
'[class*="chip"], [class*="Chip"], [class*="tag"], [class*="Tag"]'
|
||||
)).filter(function(el) {
|
||||
if (!isVisible(el) || el.closest('nav, [role="navigation"]')) return false;
|
||||
var r = el.getBoundingClientRect();
|
||||
var txt = (el.textContent || "").trim();
|
||||
return r.height > 0 && r.height <= 60 && r.width <= 360 && txt.length > 0 && txt.length <= 40;
|
||||
});
|
||||
var shapeChips = Array.from(document.querySelectorAll('span, div, li, a')).slice(0, 500).filter(function(el) {
|
||||
if (!isVisible(el) || el.closest('nav, [role="navigation"]')) return false;
|
||||
var st = getComputedStyle(el);
|
||||
var r = el.getBoundingClientRect();
|
||||
var rad = parseFloat(st.borderRadius) || 0;
|
||||
var txt = (el.textContent || "").trim();
|
||||
var hasSkin = (st.backgroundColor && st.backgroundColor !== "rgba(0, 0, 0, 0)" && st.backgroundColor !== "transparent") || (parseFloat(st.borderTopWidth) || 0) > 0;
|
||||
return hasSkin && r.height > 0 && r.height <= 44 && r.width <= 260 && rad >= (r.height / 2) - 1 && txt.length > 0 && txt.length <= 24 && el.children.length <= 1;
|
||||
});
|
||||
var allChips = chipEls.concat(shapeChips);
|
||||
var chipMap = {};
|
||||
for (var chi = 0; chi < allChips.length; chi++) {
|
||||
var chs = getStyles(allChips[chi]);
|
||||
var chKey = chs.background + "|" + chs.borderRadius + "|" + chs.border;
|
||||
if (!chipMap[chKey]) {
|
||||
chipMap[chKey] = {
|
||||
label: (allChips[chi].textContent || "").trim().slice(0, 24) || "chip",
|
||||
background: chs.background, color: chs.color, padding: chs.padding, borderRadius: chs.borderRadius,
|
||||
border: chs.border, boxShadow: chs.boxShadow, fontSize: chs.fontSize, fontWeight: chs.fontWeight, height: chs.height
|
||||
};
|
||||
}
|
||||
}
|
||||
var chips = Object.values(chipMap).slice(0, 4);
|
||||
|
||||
// ── 4c. Stat / metric cells (a big numeral + a small label) ──
|
||||
var statEls = Array.from(document.querySelectorAll(
|
||||
'[class*="stat"], [class*="Stat"], [class*="metric"], [class*="Metric"], ' +
|
||||
'[class*="kpi"], [class*="KPI"], [class*="figure"], [class*="Figure"]'
|
||||
)).filter(function(el) {
|
||||
if (!isVisible(el)) return false;
|
||||
var r = el.getBoundingClientRect();
|
||||
return r.height > 0 && r.height <= 400 && r.width <= 600;
|
||||
}).slice(0, 14);
|
||||
function biggestFontChild(el) {
|
||||
var best = 0, bestEl = null, kids = el.querySelectorAll("*");
|
||||
for (var i = 0; i < kids.length; i++) {
|
||||
if (!isVisible(kids[i])) continue;
|
||||
var fz = parseFloat(getComputedStyle(kids[i]).fontSize) || 0;
|
||||
if (fz > best) { best = fz; bestEl = kids[i]; }
|
||||
}
|
||||
return bestEl;
|
||||
}
|
||||
var statMap = {};
|
||||
for (var sti = 0; sti < statEls.length; sti++) {
|
||||
var numEl = biggestFontChild(statEls[sti]) || statEls[sti];
|
||||
var numFz = parseFloat(getComputedStyle(numEl).fontSize) || 0;
|
||||
if (numFz < 28) continue; // needs a genuinely large numeral to count as a stat cell
|
||||
var cst = getStyles(statEls[sti]);
|
||||
var nst = getStyles(numEl);
|
||||
var stKey = Math.round(numFz) + "|" + cst.background;
|
||||
if (!statMap[stKey]) {
|
||||
statMap[stKey] = {
|
||||
background: cst.background, borderRadius: cst.borderRadius, border: cst.border, boxShadow: cst.boxShadow,
|
||||
numberFontSize: nst.fontSize, numberFontWeight: nst.fontWeight, numberColor: nst.color
|
||||
};
|
||||
}
|
||||
}
|
||||
var statCells = Object.values(statMap).slice(0, 3);
|
||||
|
||||
// ── 4d. Tabs ──
|
||||
var tabEls = Array.from(document.querySelectorAll(
|
||||
'[role="tab"], [class*="tab"]:not([class*="table"]):not([class*="Table"])'
|
||||
)).filter(function(el) {
|
||||
if (!isVisible(el)) return false;
|
||||
var r = el.getBoundingClientRect();
|
||||
var txt = (el.textContent || "").trim();
|
||||
return r.height > 0 && r.height <= 64 && txt.length > 0 && txt.length <= 30;
|
||||
}).slice(0, 12);
|
||||
var tabMap = {};
|
||||
for (var tbi = 0; tbi < tabEls.length; tbi++) {
|
||||
var tst = getStyles(tabEls[tbi]);
|
||||
var tKey = tst.background + "|" + tst.color + "|" + tst.border;
|
||||
if (!tabMap[tKey]) {
|
||||
tabMap[tKey] = {
|
||||
label: (tabEls[tbi].textContent || "").trim().slice(0, 24) || "tab",
|
||||
background: tst.background, color: tst.color, padding: tst.padding, borderRadius: tst.borderRadius,
|
||||
border: tst.border, boxShadow: tst.boxShadow, fontSize: tst.fontSize, fontWeight: tst.fontWeight, height: tst.height
|
||||
};
|
||||
}
|
||||
}
|
||||
var tabs = Object.values(tabMap).slice(0, 4);
|
||||
|
||||
// ── 5. Spacing scale ──
|
||||
// Collect padding and margin values from visible elements
|
||||
var spacingCounts = {};
|
||||
@@ -266,6 +392,90 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
||||
.slice(0, 5)
|
||||
.map(function(e) { return { value: e[0], count: e[1] }; });
|
||||
|
||||
// ── 8. Dominant gradient / mesh backgrounds ──
|
||||
// A site's signature color wash (Stripe/ElevenLabs/Snowflake) lives in gradient background-images
|
||||
// on large blocks — often on a pseudo-element (::before glow orbs) rather than the block itself.
|
||||
// Weight each distinct gradient by the total on-screen area it covers; return the top few.
|
||||
var gradientArea = {};
|
||||
var gradSamples = Array.from(document.querySelectorAll(
|
||||
"body, main, section, header, div, [class*='hero'], [class*='gradient'], [class*='bg'], [class*='background']"
|
||||
)).slice(0, 400);
|
||||
|
||||
// max stop chroma (max−min RGB) of a gradient: a vivid brand wash scores high, a neutral
|
||||
// white/cream scrim ~0. Used to rank washes so a small vivid gradient beats a big grey scrim.
|
||||
function gradientChroma(g) {
|
||||
var max = 0, re = /rgba?\\(\\s*(\\d+)[,\\s]+(\\d+)[,\\s]+(\\d+)/g, m;
|
||||
while ((m = re.exec(g))) {
|
||||
var r = +m[1], gr = +m[2], b = +m[3];
|
||||
var c = Math.max(r, gr, b) - Math.min(r, gr, b);
|
||||
if (c > max) max = c;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function addGradient(val, area) {
|
||||
var g = gradientOf(val);
|
||||
if (!g || area < 20000) return; // ignore tiny decorative gradients
|
||||
var norm = g.replace(/\\s+/g, " ");
|
||||
gradientArea[norm] = (gradientArea[norm] || 0) + area;
|
||||
}
|
||||
|
||||
for (var gi = 0; gi < gradSamples.length; gi++) {
|
||||
var gel = gradSamples[gi];
|
||||
if (!isVisible(gel)) continue;
|
||||
var grect = gel.getBoundingClientRect();
|
||||
var garea = grect.width * grect.height;
|
||||
if (garea < 20000) continue;
|
||||
addGradient(getComputedStyle(gel).backgroundImage, garea);
|
||||
addGradient(getComputedStyle(gel, "::before").backgroundImage, garea);
|
||||
addGradient(getComputedStyle(gel, "::after").backgroundImage, garea);
|
||||
}
|
||||
|
||||
// rank by chroma-weighted area so the brand's signature color wash outranks a larger neutral scrim
|
||||
var backgrounds = Object.entries(gradientArea)
|
||||
.map(function(e) { return { value: e[0], area: Math.round(e[1]), score: e[1] * (1 + 2 * gradientChroma(e[0]) / 255) }; })
|
||||
.sort(function(a, b) { return b.score - a.score; })
|
||||
.slice(0, 6)
|
||||
.map(function(e) { return { value: e.value, area: e.area }; });
|
||||
|
||||
// ── 9. Frosted-glass panels (backdrop-filter) ──
|
||||
// A defining material on modern hero UIs (HeyGen's prompt box, Stripe's floating chrome): a
|
||||
// translucent surface with a backdrop blur. Capture the RAW fill (rgba/gradient — alpha preserved,
|
||||
// unlike rgbToHex) + the blur, ranked by area. This is what lets a frame render a real frosted card.
|
||||
var glassSamples = Array.from(document.querySelectorAll(
|
||||
"div, section, header, nav, aside, [class*='card'], [class*='panel'], [class*='glass'], [class*='blur'], [class*='modal'], [class*='overlay'], [class*='input']"
|
||||
)).slice(0, 400);
|
||||
var glassByKey = {};
|
||||
for (var qi = 0; qi < glassSamples.length; qi++) {
|
||||
var qel = glassSamples[qi];
|
||||
if (!isVisible(qel)) continue;
|
||||
var qs = getComputedStyle(qel);
|
||||
var bf = qs.backdropFilter || qs.webkitBackdropFilter || "";
|
||||
if (!bf || bf === "none" || bf.indexOf("blur") < 0) continue;
|
||||
var qrect = qel.getBoundingClientRect();
|
||||
var qarea = qrect.width * qrect.height;
|
||||
if (qarea < 8000) continue; // ignore tiny blurred chips
|
||||
// raw fill with alpha intact: prefer a translucent gradient, else the rgba background-color
|
||||
var gi2 = qs.backgroundImage;
|
||||
var rawFill = gi2 && gi2.indexOf("gradient") >= 0 ? gi2.replace(/\\s+/g, " ").trim() : qs.backgroundColor;
|
||||
var key = bf + "|" + rawFill + "|" + qs.borderRadius;
|
||||
if (!glassByKey[key]) {
|
||||
glassByKey[key] = {
|
||||
backdropFilter: bf,
|
||||
background: rawFill,
|
||||
border: qs.border,
|
||||
borderRadius: qs.borderRadius,
|
||||
boxShadow: qs.boxShadow === "none" ? "" : qs.boxShadow,
|
||||
area: 0
|
||||
};
|
||||
}
|
||||
glassByKey[key].area += qarea;
|
||||
}
|
||||
var glass = Object.values(glassByKey)
|
||||
.sort(function(a, b) { return b.area - a.area; })
|
||||
.slice(0, 3)
|
||||
.map(function(g) { return { backdropFilter: g.backdropFilter, background: g.background, border: g.border, borderRadius: g.borderRadius, boxShadow: g.boxShadow, area: Math.round(g.area) }; });
|
||||
|
||||
return {
|
||||
typography: uniqueTypo,
|
||||
spacing: { observed: observedSpacing.slice(0, 15), baseUnit: baseUnit },
|
||||
@@ -273,7 +483,12 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
||||
shadows: shadows,
|
||||
buttons: buttons,
|
||||
cards: cards,
|
||||
nav: nav
|
||||
nav: nav,
|
||||
chips: chips,
|
||||
statCells: statCells,
|
||||
tabs: tabs,
|
||||
backgrounds: backgrounds,
|
||||
glass: glass
|
||||
};
|
||||
})()`;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
canonicalizeFamily,
|
||||
extractFontMetadata,
|
||||
inferWeightFromSubfamily,
|
||||
isIconCharacterSet,
|
||||
} from "./fontMetadataExtractor.js";
|
||||
|
||||
describe("inferWeightFromSubfamily", () => {
|
||||
@@ -174,3 +175,41 @@ describe("extractFontMetadata", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIconCharacterSet", () => {
|
||||
// codepoint helpers
|
||||
const latin = Array.from({ length: 26 }, (_, i) => 0x41 + i); // A-Z
|
||||
const pua = Array.from({ length: 20 }, (_, i) => 0xe000 + i); // Private Use Area
|
||||
const puaSupp = [0xf0000, 0xf0001, 0x10fffd]; // supplementary PUA-A/B
|
||||
|
||||
it("flags a font whose glyphs are mostly Private-Use-Area (an icon font)", () => {
|
||||
// ~63% PUA, mirroring a real "hushly" icon set
|
||||
expect(isIconCharacterSet([...pua, ...latin.slice(0, 12)])).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a near-100% PUA set (Font-Awesome-style)", () => {
|
||||
expect(isIconCharacterSet([...pua, ...puaSupp])).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT flag a normal Latin text font", () => {
|
||||
expect(isIconCharacterSet(latin)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT flag a unicode-range subset with no Latin letters but 0% PUA", () => {
|
||||
// e.g. a cyrillic/latin-ext subset served by Next.js/Google Fonts — the false-positive case
|
||||
const cyrillic = Array.from({ length: 40 }, (_, i) => 0x0410 + i);
|
||||
expect(isIconCharacterSet(cyrillic)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT flag a PUA-heavy TEXT font that still has a full Latin alphabet", () => {
|
||||
// Apple SF Pro (~81% PUA, ships SF Symbols in the PUA) and Descript's Booton (~50% PUA) are
|
||||
// full text fonts — the Latin alphabet gate must keep them out of the icon bucket.
|
||||
const fullAlphabet = latin; // A-Z (26)
|
||||
const sfProLike = [...fullAlphabet, ...Array.from({ length: 200 }, (_, i) => 0xe000 + i)];
|
||||
expect(isIconCharacterSet(sfProLike)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an empty character set", () => {
|
||||
expect(isIconCharacterSet([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,12 @@ export interface FontFileMetadata {
|
||||
variationAxes: string[];
|
||||
/** Whether identification came from the binary name table (the trustworthy source). */
|
||||
identified: boolean;
|
||||
/**
|
||||
* True when this is an ICON font — it has no basic Latin letters, or its glyphs live mostly in
|
||||
* the Unicode Private Use Area (Font Awesome, swiper-icons, a custom "hushly" icon set, …).
|
||||
* Consumers must NOT treat it as a text family: binding it to one renders headings as tofu/icons.
|
||||
*/
|
||||
isIcon: boolean;
|
||||
}
|
||||
|
||||
export interface FontFamilySummary {
|
||||
@@ -149,6 +155,7 @@ function readSingleFont(fullPath: string, filename: string): FontFileMetadata {
|
||||
style: "normal",
|
||||
variationAxes: [],
|
||||
identified: false,
|
||||
isIcon: false,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -185,12 +192,45 @@ function readSingleFont(fullPath: string, filename: string): FontFileMetadata {
|
||||
style,
|
||||
variationAxes,
|
||||
identified: true,
|
||||
isIcon: detectIconFont(font),
|
||||
};
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect an ICON font by glyph coverage rather than by name (icon fonts often have arbitrary
|
||||
* names like "hushly" or "swiper-icons" that no name-list can enumerate). See isIconCharacterSet
|
||||
* for the rule (lacks a Latin alphabet AND mostly Private-Use-Area glyphs). `characterSet` is a
|
||||
* fontkit runtime member not always in its typings, so it's read through a narrow local shape.
|
||||
*/
|
||||
function detectIconFont(font: Font): boolean {
|
||||
const f = font as unknown as { characterSet?: number[] };
|
||||
try {
|
||||
return isIconCharacterSet(Array.isArray(f.characterSet) ? f.characterSet : []);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect an icon font from glyph coverage. Two conditions must BOTH hold:
|
||||
* 1. it lacks a real Latin alphabet (< 26 of A-Za-z) — a text font ships the full alphabet;
|
||||
* 2. most of its glyphs (> 50%) live in a Unicode Private Use Area.
|
||||
* The Latin gate is essential: some text fonts pack thousands of PUA glyphs yet are plainly text —
|
||||
* Apple's SF Pro (81% PUA, full A-Za-z, ships SF Symbols in the PUA), Descript's Booton (50% PUA,
|
||||
* full A-Za-z). Flagging those by PUA ratio alone strips a brand's real typeface. Measured icon
|
||||
* fonts: "hushly" 63% PUA / 7 letters, Font Awesome 95% PUA / 0 letters. Exported for testing.
|
||||
*/
|
||||
export function isIconCharacterSet(characterSet: number[]): boolean {
|
||||
if (!characterSet.length) return false;
|
||||
const isLatinLetter = (cp: number) => (cp >= 0x41 && cp <= 0x5a) || (cp >= 0x61 && cp <= 0x7a);
|
||||
if (characterSet.filter(isLatinLetter).length >= 26) return false; // has an alphabet → a text font
|
||||
const inPua = (cp: number) => (cp >= 0xe000 && cp <= 0xf8ff) || (cp >= 0xf0000 && cp <= 0x10fffd);
|
||||
return characterSet.filter(inPua).length / characterSet.length > 0.5;
|
||||
}
|
||||
|
||||
/** Aggregate per-file entries into per-family summaries — most useful shape for DESIGN.md. */
|
||||
// fallow-ignore-next-line complexity
|
||||
function aggregateFamilies(files: FontFileMetadata[]): FontFamilySummary[] {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { extractTokens } from "./tokenExtractor.js";
|
||||
import { extractDesignStyles } from "./designStyleExtractor.js";
|
||||
import { downloadAssets, downloadAndRewriteFonts } from "./assetDownloader.js";
|
||||
import { extractFontMetadata } from "./fontMetadataExtractor.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
// briefGenerator.ts, visual-style, capture-summary removed — DESIGN.md replaces them
|
||||
import {
|
||||
setupAnimationCapture,
|
||||
@@ -353,7 +354,8 @@ export async function captureWebsite(
|
||||
`${designStyles.typography.length} typography roles, ${designStyles.buttons.length} button styles, ${designStyles.shadows.length} shadow values extracted`,
|
||||
);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? `${err.message}\n${err.stack}` : String(err);
|
||||
const errMsg =
|
||||
err instanceof Error ? `${err.message}\n${err.stack}` : normalizeErrorMessage(err);
|
||||
console.error(` ⚠ Design style extraction failed: ${errMsg}`);
|
||||
warnings.push(`Design style extraction failed: ${errMsg}`);
|
||||
}
|
||||
@@ -480,10 +482,7 @@ export async function captureWebsite(
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"Font metadata extraction failed (non-fatal):",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
console.warn("Font metadata extraction failed (non-fatal):", normalizeErrorMessage(err));
|
||||
}
|
||||
|
||||
// Save animation catalog — lean version for the agent (not 745 raw CSS declarations)
|
||||
|
||||
@@ -277,6 +277,8 @@ interface VideoDescriptor {
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
top: number;
|
||||
left: number;
|
||||
heading: string;
|
||||
@@ -315,8 +317,14 @@ const VIDEO_SCAN_EXPR = `(() => {
|
||||
if (!ariaLabel && wrapper) ariaLabel = wrapper.getAttribute('aria-label') || '';
|
||||
return {
|
||||
src: src,
|
||||
// width/height are the DOM display box (what the page laid the element out
|
||||
// at); sourceWidth/Height are the clip's intrinsic resolution. Size planners
|
||||
// off the source dims, not the display box (a 1920x1080 clip can display at
|
||||
// 904x613). 0 when metadata has not loaded yet.
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
sourceWidth: v.videoWidth || 0,
|
||||
sourceHeight: v.videoHeight || 0,
|
||||
top: Math.round(rect.top),
|
||||
left: Math.round(rect.left),
|
||||
heading: heading,
|
||||
@@ -418,6 +426,8 @@ export async function captureVideoManifest(
|
||||
filename: k,
|
||||
width: 0,
|
||||
height: 0,
|
||||
sourceWidth: 0,
|
||||
sourceHeight: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
heading: "",
|
||||
@@ -441,6 +451,8 @@ export async function captureVideoManifest(
|
||||
filename: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
heading: string;
|
||||
caption: string;
|
||||
ariaLabel: string;
|
||||
@@ -508,6 +520,8 @@ export async function captureVideoManifest(
|
||||
filename: v.filename,
|
||||
width: v.width,
|
||||
height: v.height,
|
||||
sourceWidth: v.sourceWidth,
|
||||
sourceHeight: v.sourceHeight,
|
||||
heading: v.heading,
|
||||
caption: v.caption,
|
||||
ariaLabel: v.ariaLabel,
|
||||
|
||||
@@ -179,6 +179,10 @@ export interface TypographyRole {
|
||||
export interface ComponentStyle {
|
||||
label: string;
|
||||
background: string;
|
||||
/** Gradient background-image if any (url()/none dropped) — a core brand signal */
|
||||
backgroundImage?: string;
|
||||
/** backdrop-filter value if any (frosted-glass panels); "" when none */
|
||||
backdropFilter?: string;
|
||||
color: string;
|
||||
padding: string;
|
||||
borderRadius: string;
|
||||
@@ -189,6 +193,17 @@ export interface ComponentStyle {
|
||||
height: string;
|
||||
}
|
||||
|
||||
export interface StatCellStyle {
|
||||
background: string;
|
||||
borderRadius: string;
|
||||
border: string;
|
||||
boxShadow: string;
|
||||
/** the large numeral's type */
|
||||
numberFontSize: string;
|
||||
numberFontWeight: string;
|
||||
numberColor: string;
|
||||
}
|
||||
|
||||
export interface DesignStyles {
|
||||
typography: TypographyRole[];
|
||||
spacing: {
|
||||
@@ -200,6 +215,23 @@ export interface DesignStyles {
|
||||
buttons: ComponentStyle[];
|
||||
cards: ComponentStyle[];
|
||||
nav: ComponentStyle | null;
|
||||
/** pill / badge / chip / tag — small rounded labelled elements */
|
||||
chips?: ComponentStyle[];
|
||||
/** metric / KPI cells (a large numeral + label) */
|
||||
statCells?: StatCellStyle[];
|
||||
/** tab controls */
|
||||
tabs?: ComponentStyle[];
|
||||
/** Dominant gradient/mesh background washes, ranked by on-screen area covered */
|
||||
backgrounds?: Array<{ value: string; area: number }>;
|
||||
/** Frosted-glass panels (backdrop-filter): raw translucent fill + blur, ranked by area */
|
||||
glass?: Array<{
|
||||
backdropFilter: string;
|
||||
background: string;
|
||||
border: string;
|
||||
borderRadius: string;
|
||||
boxShadow: string;
|
||||
area: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ── Assets ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const cliSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "cli.ts"), "utf8");
|
||||
const helpSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "help.ts"), "utf8");
|
||||
|
||||
function commandLoaderBlock(): string {
|
||||
const match = cliSource.match(/const commandLoaders = \{([\s\S]*?)\n\};/);
|
||||
expect(match).toBeTruthy();
|
||||
return match![1]!;
|
||||
}
|
||||
|
||||
describe("CLI command registration", () => {
|
||||
it("registers keyframes as the only keyframe inspection command", () => {
|
||||
const loaders = commandLoaderBlock();
|
||||
|
||||
expect(loaders).toMatch(/\bkeyframes:\s*\(\)\s*=>\s*import\("\.\/commands\/keyframes\.js"\)/);
|
||||
expect(loaders).not.toMatch(/\bmotion:\s*\(\)\s*=>/);
|
||||
expect(loaders).not.toContain("./commands/motion.js");
|
||||
});
|
||||
|
||||
it("shows keyframes in root help", () => {
|
||||
expect(helpSource).toContain(
|
||||
'["keyframes", "Inspect keyframes and render onion-shot diagnostics"]',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,7 @@ const commandLoaders = {
|
||||
lint: () => import("./commands/lint.js").then((m) => m.default),
|
||||
beats: () => import("./commands/beats.js").then((m) => m.default),
|
||||
inspect: () => import("./commands/inspect.js").then((m) => m.default),
|
||||
keyframes: () => import("./commands/keyframes.js").then((m) => m.default),
|
||||
layout: () => import("./commands/layout.js").then((m) => m.default),
|
||||
info: () => import("./commands/info.js").then((m) => m.default),
|
||||
compositions: () => import("./commands/compositions.js").then((m) => m.default),
|
||||
@@ -143,6 +144,7 @@ const commandLoaders = {
|
||||
cloudrun: () => import("./commands/cloudrun.js").then((m) => m.default),
|
||||
cloud: () => import("./commands/cloud.js").then((m) => m.default),
|
||||
auth: () => import("./commands/auth.js").then((m) => m.default),
|
||||
figma: () => import("./commands/figma.js").then((m) => m.default),
|
||||
};
|
||||
|
||||
// Wrap each command's run() so a thrown failure reports its reason to telemetry
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
* experiment-framework to regenerate.
|
||||
*/
|
||||
import type {
|
||||
CompleteAssetUploadRequest,
|
||||
CompleteAssetUploadResponse,
|
||||
CreateAssetUploadRequest,
|
||||
CreateAssetUploadResponse,
|
||||
CreateHyperframesRenderRequest,
|
||||
CreateHyperframesRenderResponse,
|
||||
DeleteHyperframesRenderResponse,
|
||||
@@ -225,6 +229,43 @@ export class HyperframesCloudClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Asset Upload
|
||||
*
|
||||
* Begin a direct-to-S3 upload. Returns an asset_id and a presigned upload_url; PUT the file bytes to upload_url, then call POST /v3/assets/{asset_id}/complete. Unlike POST /v3/assets (which proxies the bytes), this never sends the file through the API.
|
||||
*/
|
||||
async createAssetUpload(args: {
|
||||
body: CreateAssetUploadRequest;
|
||||
idempotencyKey?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<CreateAssetUploadResponse> {
|
||||
return await this.request<CreateAssetUploadResponse>({
|
||||
method: "POST",
|
||||
path: "/v3/assets/direct-uploads",
|
||||
body: args.body,
|
||||
idempotencyKey: args.idempotencyKey,
|
||||
signal: args.signal,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete Asset Upload
|
||||
*
|
||||
* Finalize a direct-to-S3 upload into a reusable asset. Call after the upload PUT returns 200. Idempotent: repeated calls return the same finalized asset.
|
||||
*/
|
||||
async completeAssetUpload(args: {
|
||||
asset_id: string;
|
||||
body: CompleteAssetUploadRequest;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<CompleteAssetUploadResponse> {
|
||||
return await this.request<CompleteAssetUploadResponse>({
|
||||
method: "POST",
|
||||
path: `/v3/assets/${encodeURIComponent(args.asset_id)}/complete`,
|
||||
body: args.body,
|
||||
signal: args.signal,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HyperFrames Render
|
||||
*
|
||||
|
||||
@@ -55,6 +55,100 @@ export interface AssetUrl {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a presigned upload (POST /v3/assets/{asset_id}/complete).
|
||||
*/
|
||||
export interface CompleteAssetUploadRequest {
|
||||
/**
|
||||
* Optional SHA256 (hex) cross-check.
|
||||
*/
|
||||
checksum_sha256?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of finalizing an upload.
|
||||
*/
|
||||
export interface CompleteAssetUploadResponse {
|
||||
/**
|
||||
* The reusable asset identifier.
|
||||
*/
|
||||
asset_id: string;
|
||||
/**
|
||||
* Public URL of the finalized asset.
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* MIME type detected from the stored bytes.
|
||||
*/
|
||||
mime_type: string;
|
||||
/**
|
||||
* Size of the stored object in bytes.
|
||||
*/
|
||||
size_bytes: number;
|
||||
/**
|
||||
* Asset status, e.g. 'processing'.
|
||||
*/
|
||||
status: "processing";
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to begin a presigned direct-to-S3 upload (POST
|
||||
* /v3/assets/direct-uploads).
|
||||
*/
|
||||
export interface CreateAssetUploadRequest {
|
||||
/**
|
||||
* Original filename for reference/metadata. The stored object's extension is
|
||||
* derived from content_type.
|
||||
*/
|
||||
filename: string;
|
||||
/**
|
||||
* Declared MIME type (e.g. 'video/mp4', 'image/png', 'audio/mpeg',
|
||||
* 'application/pdf', 'application/zip'). Verified against the stored bytes at
|
||||
* completion.
|
||||
*/
|
||||
content_type: string;
|
||||
/**
|
||||
* Exact byte size of the file. Signed into the upload URL so it cannot be
|
||||
* exceeded.
|
||||
*/
|
||||
size_bytes: number;
|
||||
/**
|
||||
* Optional SHA256 of the file as hex. When provided, S3 enforces it on upload.
|
||||
*/
|
||||
checksum_sha256?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presigned upload instructions.
|
||||
*/
|
||||
export interface CreateAssetUploadResponse {
|
||||
/**
|
||||
* Reusable asset identifier. Becomes usable after POST
|
||||
* /v3/assets/{asset_id}/complete.
|
||||
*/
|
||||
asset_id: string;
|
||||
/**
|
||||
* Presigned S3 URL. PUT the raw file bytes here.
|
||||
*/
|
||||
upload_url: string;
|
||||
/**
|
||||
* Headers that must be sent verbatim on the PUT request.
|
||||
*/
|
||||
upload_headers: Record<string, unknown>;
|
||||
/**
|
||||
* Seconds until the upload URL expires.
|
||||
*/
|
||||
expires_in_seconds: number;
|
||||
/**
|
||||
* Maximum allowed upload size in bytes.
|
||||
*/
|
||||
max_bytes: number;
|
||||
/**
|
||||
* Upload lifecycle status. Always 'pending_upload' here.
|
||||
*/
|
||||
status: "pending_upload";
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for POST /v3/hyperframes/renders.
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
|
||||
export type SupportedAspectRatio = "16:9" | "9:16" | "1:1";
|
||||
|
||||
@@ -87,7 +88,7 @@ export function detectAspectRatioFromHtml(entryHtmlPath: string): AspectRatioDet
|
||||
try {
|
||||
html = readFileSync(entryHtmlPath, "utf-8");
|
||||
} catch (err) {
|
||||
return { kind: "read-error", error: err instanceof Error ? err.message : String(err) };
|
||||
return { kind: "read-error", error: normalizeErrorMessage(err) };
|
||||
}
|
||||
return detectAspectRatioFromHtmlString(html);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { HyperframesApiError } from "./_gen/client.js";
|
||||
*/
|
||||
const ERROR_CODE_HINTS: Record<string, string> = {
|
||||
hyperframes_project_too_large:
|
||||
"The zip exceeded the 32 MB limit. Trim large media (or pre-host them and reference by URL), then try again.",
|
||||
"The zip exceeded the 200 MB limit. Trim large media (or pre-host them and reference by URL), then try again.",
|
||||
hyperframes_render_not_found:
|
||||
"The render_id no longer exists — either soft-deleted or never created.",
|
||||
invalid_parameter:
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createHash } from "node:crypto";
|
||||
import { HyperframesApiError } from "./_gen/client.js";
|
||||
import type { HyperframesCloudClient } from "./_gen/client.js";
|
||||
import { uploadZipViaDirectUpload } from "./upload.js";
|
||||
|
||||
function sha256Hex(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function makeClient(overrides: Partial<HyperframesCloudClient> = {}): HyperframesCloudClient {
|
||||
return {
|
||||
createAssetUpload: vi.fn(async () => ({
|
||||
asset_id: "asset_xyz",
|
||||
upload_url: "https://s3.example/asset_xyz?sig=abc",
|
||||
upload_headers: { "x-amz-checksum-sha256-b64": "..." },
|
||||
expires_in_seconds: 3600,
|
||||
max_bytes: 200 * 1024 * 1024,
|
||||
status: "pending_upload" as const,
|
||||
})),
|
||||
completeAssetUpload: vi.fn(async () => ({
|
||||
asset_id: "asset_xyz",
|
||||
url: "https://files.heygen.com/document/asset_xyz/original.zip",
|
||||
mime_type: "application/zip",
|
||||
size_bytes: 42,
|
||||
status: "processing" as const,
|
||||
})),
|
||||
...overrides,
|
||||
} as unknown as HyperframesCloudClient;
|
||||
}
|
||||
|
||||
function makeFetchOk(): typeof fetch {
|
||||
return vi.fn(async () => new Response("", { status: 200 })) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("uploadZipViaDirectUpload", () => {
|
||||
it("sends the correct filename, content_type, size_bytes, and SHA256 to createAssetUpload", async () => {
|
||||
const bytes = new TextEncoder().encode("hello-hyperframes-zip");
|
||||
const expectedSha = sha256Hex(bytes);
|
||||
const client = makeClient();
|
||||
const fetchImpl = makeFetchOk();
|
||||
|
||||
await uploadZipViaDirectUpload({
|
||||
client,
|
||||
bytes,
|
||||
filename: "my-comp.zip",
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
expect(client.createAssetUpload).toHaveBeenCalledOnce();
|
||||
const arg = (client.createAssetUpload as ReturnType<typeof vi.fn>).mock.calls[0]![0];
|
||||
expect(arg.body).toEqual({
|
||||
filename: "my-comp.zip",
|
||||
content_type: "application/zip",
|
||||
size_bytes: bytes.byteLength,
|
||||
checksum_sha256: expectedSha,
|
||||
});
|
||||
});
|
||||
|
||||
it("PUTs to the returned upload_url with the returned upload_headers verbatim + content-type", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3, 4]);
|
||||
const client = makeClient({
|
||||
createAssetUpload: vi.fn(async () => ({
|
||||
asset_id: "asset_xyz",
|
||||
upload_url: "https://s3.example/target?sig=xxx",
|
||||
upload_headers: { "x-signed-header": "signed-value", "x-other": "other-value" },
|
||||
expires_in_seconds: 3600,
|
||||
max_bytes: 200 * 1024 * 1024,
|
||||
status: "pending_upload" as const,
|
||||
})) as HyperframesCloudClient["createAssetUpload"],
|
||||
});
|
||||
const fetchImpl = makeFetchOk();
|
||||
|
||||
await uploadZipViaDirectUpload({ client, bytes, filename: "x.zip", fetchImpl });
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
const [url, init] = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls[0]!;
|
||||
expect(url).toBe("https://s3.example/target?sig=xxx");
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(init.body).toBe(bytes);
|
||||
expect(init.headers).toEqual({
|
||||
"content-type": "application/zip",
|
||||
"x-signed-header": "signed-value",
|
||||
"x-other": "other-value",
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT attach CLI auth headers to the S3 PUT — presigned URL carries auth", async () => {
|
||||
const client = makeClient();
|
||||
const fetchImpl = makeFetchOk();
|
||||
|
||||
await uploadZipViaDirectUpload({
|
||||
client,
|
||||
bytes: new Uint8Array([0]),
|
||||
filename: "x.zip",
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
const init = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls[0]![1];
|
||||
// No Authorization / x-api-key / Bearer header should be present.
|
||||
const headerKeys = Object.keys(init.headers as Record<string, string>).map((k) =>
|
||||
k.toLowerCase(),
|
||||
);
|
||||
expect(headerKeys).not.toContain("authorization");
|
||||
expect(headerKeys).not.toContain("x-api-key");
|
||||
});
|
||||
|
||||
it("calls completeAssetUpload with the initialize's asset_id + same checksum", async () => {
|
||||
const bytes = new TextEncoder().encode("determinism");
|
||||
const expectedSha = sha256Hex(bytes);
|
||||
const client = makeClient();
|
||||
const fetchImpl = makeFetchOk();
|
||||
|
||||
const result = await uploadZipViaDirectUpload({
|
||||
client,
|
||||
bytes,
|
||||
filename: "x.zip",
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
expect(client.completeAssetUpload).toHaveBeenCalledOnce();
|
||||
const completeArg = (client.completeAssetUpload as ReturnType<typeof vi.fn>).mock.calls[0]![0];
|
||||
expect(completeArg.asset_id).toBe("asset_xyz");
|
||||
expect(completeArg.body).toEqual({ checksum_sha256: expectedSha });
|
||||
expect(result.asset_id).toBe("asset_xyz");
|
||||
expect(result.size_bytes).toBe(bytes.byteLength);
|
||||
});
|
||||
|
||||
it("retries completeAssetUpload on 409 and succeeds on a later attempt", async () => {
|
||||
let completeCalls = 0;
|
||||
const completeAssetUpload = vi.fn(async () => {
|
||||
completeCalls++;
|
||||
if (completeCalls < 3) {
|
||||
throw new HyperframesApiError({
|
||||
status: 409,
|
||||
message: "Uploaded object not found yet. Retry after upload PUT returns 200.",
|
||||
code: "conflict",
|
||||
});
|
||||
}
|
||||
return {
|
||||
asset_id: "asset_xyz",
|
||||
url: "u",
|
||||
mime_type: "application/zip",
|
||||
size_bytes: 1,
|
||||
status: "processing" as const,
|
||||
};
|
||||
});
|
||||
const client = makeClient({
|
||||
completeAssetUpload:
|
||||
completeAssetUpload as unknown as HyperframesCloudClient["completeAssetUpload"],
|
||||
});
|
||||
|
||||
const result = await uploadZipViaDirectUpload({
|
||||
client,
|
||||
bytes: new Uint8Array([0]),
|
||||
filename: "x.zip",
|
||||
fetchImpl: makeFetchOk(),
|
||||
});
|
||||
|
||||
expect(completeCalls).toBe(3);
|
||||
expect(result.asset_id).toBe("asset_xyz");
|
||||
});
|
||||
|
||||
it("surfaces non-409 errors from complete without retrying", async () => {
|
||||
let completeCalls = 0;
|
||||
const completeAssetUpload = vi.fn(async () => {
|
||||
completeCalls++;
|
||||
throw new HyperframesApiError({
|
||||
status: 400,
|
||||
message: "invalid checksum",
|
||||
code: "invalid_parameter",
|
||||
});
|
||||
});
|
||||
const client = makeClient({
|
||||
completeAssetUpload:
|
||||
completeAssetUpload as unknown as HyperframesCloudClient["completeAssetUpload"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadZipViaDirectUpload({
|
||||
client,
|
||||
bytes: new Uint8Array([0]),
|
||||
filename: "x.zip",
|
||||
fetchImpl: makeFetchOk(),
|
||||
}),
|
||||
).rejects.toThrow(/invalid checksum/);
|
||||
expect(completeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("surfaces PUT failures with response body detail", async () => {
|
||||
const client = makeClient();
|
||||
const fetchImpl = vi.fn(
|
||||
async () =>
|
||||
new Response("SignatureDoesNotMatch: request signature we calculated does not match", {
|
||||
status: 403,
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
uploadZipViaDirectUpload({
|
||||
client,
|
||||
bytes: new Uint8Array([0]),
|
||||
filename: "x.zip",
|
||||
fetchImpl,
|
||||
}),
|
||||
).rejects.toThrow(/Direct upload PUT failed: 403.*SignatureDoesNotMatch/);
|
||||
});
|
||||
|
||||
it("passes idempotencyKey through to createAssetUpload", async () => {
|
||||
const client = makeClient();
|
||||
await uploadZipViaDirectUpload({
|
||||
client,
|
||||
bytes: new Uint8Array([0]),
|
||||
filename: "x.zip",
|
||||
idempotencyKey: "test-key-123",
|
||||
fetchImpl: makeFetchOk(),
|
||||
});
|
||||
const arg = (client.createAssetUpload as ReturnType<typeof vi.fn>).mock.calls[0]![0];
|
||||
expect(arg.idempotencyKey).toBe("test-key-123");
|
||||
});
|
||||
|
||||
it("emits progress events in order", async () => {
|
||||
const client = makeClient();
|
||||
const events: Array<{ phase: string }> = [];
|
||||
await uploadZipViaDirectUpload({
|
||||
client,
|
||||
bytes: new Uint8Array([0]),
|
||||
filename: "x.zip",
|
||||
fetchImpl: makeFetchOk(),
|
||||
onProgress: (e) => events.push({ phase: e.phase }),
|
||||
});
|
||||
expect(events.map((e) => e.phase)).toEqual(["initialize", "upload", "upload", "complete"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Direct-upload flow for `hyperframes cloud render` project asset uploads.
|
||||
*
|
||||
* The legacy `POST /v3/assets` path proxies bytes through the API and is
|
||||
* capped at 32 MB in-memory. This module implements the three-step
|
||||
* direct-to-S3 flow the CLI now uses instead, lifting the practical
|
||||
* per-project ceiling to 200 MB (the direct-upload cap enforced by the
|
||||
* signed presigned URL):
|
||||
*
|
||||
* 1. `client.createAssetUpload({filename, content_type, size_bytes,
|
||||
* checksum_sha256})` → returns `{asset_id, upload_url,
|
||||
* upload_headers, expires_in_seconds, max_bytes}`.
|
||||
* 2. Raw `PUT` to `upload_url` with the zip bytes + `upload_headers`
|
||||
* verbatim. No CLI auth headers on this call — the presigned URL
|
||||
* signature carries authorization.
|
||||
* 3. `client.completeAssetUpload({asset_id, body: {checksum_sha256}})`
|
||||
* to finalize. Docs explicitly note a 409 "Uploaded object not
|
||||
* found yet" is possible if the PUT hasn't been fully committed
|
||||
* server-side; a small retry loop absorbs that.
|
||||
*
|
||||
* The returned `asset_id` is the same id namespace the legacy path
|
||||
* produced, so `createRender({project: {type: "asset_id", asset_id}})`
|
||||
* on the render side is a drop-in swap.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import type { HyperframesCloudClient } from "./_gen/client.js";
|
||||
import { HyperframesApiError } from "./_gen/client.js";
|
||||
|
||||
export interface UploadZipViaDirectResult {
|
||||
asset_id: string;
|
||||
size_bytes: number;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
export type UploadProgressEvent =
|
||||
| { phase: "initialize" }
|
||||
| { phase: "upload"; percent: number }
|
||||
| { phase: "complete" };
|
||||
|
||||
export interface UploadZipViaDirectOptions {
|
||||
client: HyperframesCloudClient;
|
||||
bytes: Uint8Array;
|
||||
filename: string;
|
||||
idempotencyKey?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
onProgress?: (event: UploadProgressEvent) => void;
|
||||
}
|
||||
|
||||
// Per api-docs: completeAssetUpload can return 409 "Uploaded object not found
|
||||
// yet" if the S3 PUT's write hasn't propagated to the read plane by the time
|
||||
// complete runs. Small backoff loop absorbs it.
|
||||
const COMPLETE_MAX_RETRIES = 5;
|
||||
const COMPLETE_RETRY_BASE_MS = 500;
|
||||
const CONTENT_TYPE_ZIP = "application/zip";
|
||||
|
||||
function sha256Hex(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// upload_headers is generated as `Record<string, unknown>` from the
|
||||
// OpenAPI spec, but the values are always strings at runtime (HTTP header
|
||||
// values). Coerce defensively so a spec quirk can't slip a non-string in.
|
||||
function normalizeUploadHeaders(raw: Record<string, unknown>): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(raw ?? {})) {
|
||||
if (typeof v === "string") out[k] = v;
|
||||
else if (typeof v === "number" || typeof v === "boolean") out[k] = String(v);
|
||||
// Skip anything else — better to omit than send `[object Object]`.
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// PUT bytes to the presigned URL. Do NOT attach CLI auth headers; the
|
||||
// presigned URL signature carries authorization and the signature is
|
||||
// bound to the *headers signed at presign time* — extra headers invalidate
|
||||
// the signature (S3 returns 403). Content-Type must match the declared
|
||||
// content_type from step 1 (also signed).
|
||||
async function putBytesToPresignedUrl(
|
||||
fetchImpl: typeof fetch,
|
||||
uploadUrl: string,
|
||||
uploadHeaders: Record<string, unknown>,
|
||||
bytes: Uint8Array,
|
||||
): Promise<void> {
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": CONTENT_TYPE_ZIP,
|
||||
...normalizeUploadHeaders(uploadHeaders),
|
||||
};
|
||||
// `Uint8Array<ArrayBufferLike>` is a valid `BodyInit` at runtime but
|
||||
// not strictly assignable per lib.dom.d.ts — cast rather than copy,
|
||||
// since a 200MB buffer copy would be wasteful.
|
||||
const res = await fetchImpl(uploadUrl, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: bytes as unknown as BodyInit,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Direct upload PUT failed: ${res.status} ${res.statusText}${
|
||||
detail ? ` — ${detail.slice(0, 300)}` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Complete with retry-on-409. Retry ONLY on the documented "PUT not
|
||||
// visible yet" race between S3 write consistency and finalize; any other
|
||||
// error surfaces immediately. `completeAssetUpload` itself is idempotent,
|
||||
// so retrying an already-succeeded call is safe.
|
||||
async function completeWithRetry(
|
||||
client: HyperframesCloudClient,
|
||||
asset_id: string,
|
||||
checksum_sha256: string,
|
||||
): Promise<{ asset_id: string }> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < COMPLETE_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await client.completeAssetUpload({
|
||||
asset_id,
|
||||
body: { checksum_sha256 },
|
||||
});
|
||||
} catch (err) {
|
||||
const retryable = err instanceof HyperframesApiError && err.status === 409;
|
||||
if (!retryable || attempt === COMPLETE_MAX_RETRIES - 1) {
|
||||
throw err;
|
||||
}
|
||||
lastErr = err;
|
||||
await sleep(COMPLETE_RETRY_BASE_MS * (attempt + 1));
|
||||
}
|
||||
}
|
||||
throw lastErr ?? new Error("completeAssetUpload retries exhausted");
|
||||
}
|
||||
|
||||
export async function uploadZipViaDirectUpload(
|
||||
opts: UploadZipViaDirectOptions,
|
||||
): Promise<UploadZipViaDirectResult> {
|
||||
const start = Date.now();
|
||||
const { bytes, filename, idempotencyKey } = opts;
|
||||
const size_bytes = bytes.byteLength;
|
||||
const checksum_sha256 = sha256Hex(bytes);
|
||||
const fetchImpl = opts.fetchImpl ?? fetch;
|
||||
|
||||
opts.onProgress?.({ phase: "initialize" });
|
||||
const initialize = await opts.client.createAssetUpload({
|
||||
body: { filename, content_type: CONTENT_TYPE_ZIP, size_bytes, checksum_sha256 },
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
opts.onProgress?.({ phase: "upload", percent: 0 });
|
||||
await putBytesToPresignedUrl(fetchImpl, initialize.upload_url, initialize.upload_headers, bytes);
|
||||
opts.onProgress?.({ phase: "upload", percent: 100 });
|
||||
|
||||
opts.onProgress?.({ phase: "complete" });
|
||||
const completed = await completeWithRetry(opts.client, initialize.asset_id, checksum_sha256);
|
||||
return {
|
||||
asset_id: completed.asset_id,
|
||||
size_bytes,
|
||||
duration_ms: Date.now() - start,
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,17 @@ vi.mock("../../auth/index.js", async (orig) => {
|
||||
return { ...actual, AuthClient: MockAuthClient };
|
||||
});
|
||||
|
||||
// Spy on the telemetry the login flow emits, so we can assert the identity is
|
||||
// attributed on success. login.ts imports these via a dynamic import of
|
||||
// telemetry/index.js; the mock intercepts it.
|
||||
const telemetry = vi.hoisted(() => ({
|
||||
trackAuthLoginStarted: vi.fn(),
|
||||
trackAuthLoginCompleted: vi.fn(),
|
||||
trackAuthLoginFailed: vi.fn(),
|
||||
identifyUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../telemetry/index.js", () => telemetry);
|
||||
|
||||
const ENV_KEYS = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"] as const;
|
||||
|
||||
describe("auth login --api-key rollback", () => {
|
||||
@@ -46,6 +57,7 @@ describe("auth login --api-key rollback", () => {
|
||||
process.env["HEYGEN_CONFIG_DIR"] = dir;
|
||||
verifyState.reject = false;
|
||||
verifyState.user = { email: "alice@example.com" };
|
||||
for (const fn of Object.values(telemetry)) fn.mockClear();
|
||||
// process.exit throws so we can assert the post-rollback state.
|
||||
vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
@@ -158,6 +170,34 @@ describe("auth login --api-key rollback", () => {
|
||||
expect(onDisk.future_credential).toEqual({ token: "owned_by_other_cli" });
|
||||
});
|
||||
|
||||
it("attributes a successful login to the account email", async () => {
|
||||
verifyState.user = { email: "alice@example.com", username: "alice" };
|
||||
await runLogin("hg_goodkey456");
|
||||
expect(telemetry.identifyUser).toHaveBeenCalledWith("alice@example.com");
|
||||
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", "alice@example.com");
|
||||
});
|
||||
|
||||
it("falls back to username when the account has no email", async () => {
|
||||
verifyState.user = { username: "alice" };
|
||||
await runLogin("hg_goodkey456");
|
||||
expect(telemetry.identifyUser).toHaveBeenCalledWith("alice");
|
||||
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", "alice");
|
||||
});
|
||||
|
||||
it("does not identify when the identity probe returns nothing", async () => {
|
||||
verifyState.user = {}; // verified key, but no identity fields
|
||||
await runLogin("hg_goodkey456");
|
||||
expect(telemetry.identifyUser).not.toHaveBeenCalled();
|
||||
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", undefined);
|
||||
});
|
||||
|
||||
it("records a rejected key as failed and never identifies", async () => {
|
||||
verifyState.reject = true;
|
||||
await expect(runLogin("hg_badkey123")).rejects.toThrow(/process\.exit:1/);
|
||||
expect(telemetry.identifyUser).not.toHaveBeenCalled();
|
||||
expect(telemetry.trackAuthLoginFailed).toHaveBeenCalledWith("api_key", "rejected");
|
||||
});
|
||||
|
||||
it("preserves an unknown/foreign top-level key across a successful re-login", async () => {
|
||||
// Cross-CLI invariant end-to-end: a key heygen-cli (or a future
|
||||
// version) wrote must survive a hyperframes-cli login round-trip.
|
||||
|
||||
@@ -78,10 +78,19 @@ export default defineCommand({
|
||||
async function runOAuthLogin(): Promise<void> {
|
||||
assertOAuthConfiguredOrExit();
|
||||
|
||||
const { trackAuthLoginStarted, trackAuthLoginFailed } = await import("../../telemetry/index.js");
|
||||
trackAuthLoginStarted("oauth");
|
||||
|
||||
try {
|
||||
await startAuthorizationCodeFlow();
|
||||
} catch (err) {
|
||||
console.error(c.error(`Sign-in failed: ${(err as Error).message}`));
|
||||
const message = (err as Error).message ?? "";
|
||||
// The loopback server rejects with "OAuth callback timed out after …" when
|
||||
// the user never completes the browser step (closed the tab / walked away).
|
||||
// That is the dominant non-error dropout, so split it from real failures
|
||||
// (IdP misconfig, network) instead of lumping everything as flow_error.
|
||||
trackAuthLoginFailed("oauth", /timed out/i.test(message) ? "flow_timeout" : "flow_error");
|
||||
console.error(c.error(`Sign-in failed: ${message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -90,8 +99,11 @@ async function runOAuthLogin(): Promise<void> {
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function reportIdentity(): Promise<void> {
|
||||
const { trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } =
|
||||
await import("../../telemetry/index.js");
|
||||
const credential = await tryResolveCredential();
|
||||
if (!credential) {
|
||||
trackAuthLoginFailed("oauth", "no_credential");
|
||||
console.error(c.warn("Sign-in completed but no credential was persisted."));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -108,20 +120,42 @@ async function reportIdentity(): Promise<void> {
|
||||
// `auth status` can show "Logged in as ..." without re-hitting
|
||||
// /v3/users/me. Best-effort — a persist failure never fails the login.
|
||||
await persistUserInfo(user);
|
||||
// Attribute this install to the signed-in account (and stitch its prior
|
||||
// anonymous usage) before recording completion, so the completed event
|
||||
// carries the identity. Both no-op under the telemetry opt-out.
|
||||
const id = identityKey(user);
|
||||
if (id) identifyUser(id);
|
||||
trackAuthLoginCompleted("oauth", id);
|
||||
const identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)";
|
||||
console.log(c.success(`✓ Signed in as ${identity}.`));
|
||||
} catch (err) {
|
||||
// Don't roll back — the OAuth tokens are valid on disk; this is a
|
||||
// transient verify-side issue. The identity probe failed, so any
|
||||
// stale user block from a prior login (possibly a DIFFERENT account)
|
||||
// is cleared so `auth status` can't surface the wrong identity.
|
||||
// transient verify-side issue. The credential is persisted and usable, so
|
||||
// the sign-in still COMPLETED; we just have no resolved identity to
|
||||
// attribute it to. The stale user block from a prior login (possibly a
|
||||
// DIFFERENT account) is cleared so `auth status` can't surface it.
|
||||
await clearUserInfoBestEffort();
|
||||
trackAuthLoginCompleted("oauth");
|
||||
console.error(
|
||||
c.warn(`Signed in. Identity check failed (transient): ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stable key we associate this install with in telemetry after sign-in.
|
||||
* `/v3/users/me` exposes no opaque user_id, so we key on the HeyGen account
|
||||
* EMAIL — the canonical account identifier and the reliable join key back to
|
||||
* billing — falling back to username only when the account exposes no email.
|
||||
* (Username is NOT a privacy win — HeyGen usernames are frequently email-shaped
|
||||
* — it is purely a fallback so an emailless account is still attributable.)
|
||||
* The privacy notice (showTelemetryNotice) and docs/packages/cli.mdx disclose
|
||||
* both, so keep them in sync with whatever this returns.
|
||||
*/
|
||||
function identityKey(user: UserInfo): string | undefined {
|
||||
return user.email ?? user.username;
|
||||
}
|
||||
|
||||
/** Project the API `/v3/users/me` view onto the on-disk identity block. */
|
||||
function toStoredUserInfo(user: UserInfo): StoredUserInfo {
|
||||
const out: StoredUserInfo = {};
|
||||
@@ -163,8 +197,24 @@ async function clearUserInfoBestEffort(): Promise<void> {
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function runApiKeyLogin(inlineKey: string): Promise<void> {
|
||||
const key = await collectApiKey(inlineKey);
|
||||
const { trackAuthLoginStarted, trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } =
|
||||
await import("../../telemetry/index.js");
|
||||
trackAuthLoginStarted("api_key");
|
||||
|
||||
// collectApiKey throws when the user cancels the interactive prompt (Ctrl-C)
|
||||
// or when no key arrives on stdin before the timeout — both are "user walked
|
||||
// away", the abandonment signal we most want. Record it before the error
|
||||
// propagates so `started` still reconciles to `completed + failed`.
|
||||
let key: string;
|
||||
try {
|
||||
key = await collectApiKey(inlineKey);
|
||||
} catch (err) {
|
||||
trackAuthLoginFailed("api_key", "aborted");
|
||||
console.error(c.error((err as Error).message || "Sign-in aborted."));
|
||||
process.exit(1);
|
||||
}
|
||||
if (!key) {
|
||||
trackAuthLoginFailed("api_key", "invalid_input");
|
||||
console.error(c.error("No API key provided."));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -172,10 +222,12 @@ async function runApiKeyLogin(inlineKey: string): Promise<void> {
|
||||
// CR/LF in the value would smuggle headers when the key is sent
|
||||
// via `x-api-key`. The backend handles "wrong key" itself, but
|
||||
// header-injection has to be caught here.
|
||||
trackAuthLoginFailed("api_key", "invalid_input");
|
||||
console.error(c.error("API key must not contain newline or control characters."));
|
||||
process.exit(1);
|
||||
}
|
||||
if (key.length < MIN_KEY_LENGTH) {
|
||||
trackAuthLoginFailed("api_key", "invalid_input");
|
||||
console.error(c.error(`API key looks too short (got ${key.length} chars).`));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -184,11 +236,15 @@ async function runApiKeyLogin(inlineKey: string): Promise<void> {
|
||||
const next: Credentials = { ...previous, api_key: key };
|
||||
await writeStore(next);
|
||||
|
||||
const verifyOk = await verifyAndReport(key);
|
||||
if (!verifyOk) {
|
||||
const user = await verifyAndReport(key);
|
||||
if (!user) {
|
||||
trackAuthLoginFailed("api_key", "rejected");
|
||||
await rollback(previous);
|
||||
process.exit(1);
|
||||
}
|
||||
const id = identityKey(user);
|
||||
if (id) identifyUser(id);
|
||||
trackAuthLoginCompleted("api_key", id);
|
||||
}
|
||||
|
||||
async function snapshotStore(): Promise<Credentials> {
|
||||
@@ -222,8 +278,11 @@ async function rollback(previous: Credentials): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the verified user on success (so the caller can attribute the
|
||||
// completed sign-in to that identity), or null when the backend rejects the
|
||||
// key. Other errors propagate.
|
||||
// fallow-ignore-next-line complexity
|
||||
async function verifyAndReport(key: string): Promise<boolean> {
|
||||
async function verifyAndReport(key: string): Promise<UserInfo | null> {
|
||||
const client = new AuthClient();
|
||||
try {
|
||||
const user = await client.getCurrentUser({ type: "api_key", key, source: "file_json" });
|
||||
@@ -232,7 +291,7 @@ async function verifyAndReport(key: string): Promise<boolean> {
|
||||
await persistUserInfo(user);
|
||||
const identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)";
|
||||
console.log(c.success(`✓ API key saved. Authenticated as ${identity}.`));
|
||||
return true;
|
||||
return user;
|
||||
} catch (err) {
|
||||
if (isAuthError(err) && err.code === "UNAUTHENTICATED") {
|
||||
console.error(
|
||||
@@ -240,7 +299,7 @@ async function verifyAndReport(key: string): Promise<boolean> {
|
||||
` ${c.dim(err.message)}\n` +
|
||||
`Run ${c.accent("hyperframes auth login --api-key")} again with a valid key.`,
|
||||
);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -288,8 +347,9 @@ async function promptForKey(): Promise<string> {
|
||||
},
|
||||
});
|
||||
if (clack.isCancel(value)) {
|
||||
console.error("Aborted.");
|
||||
process.exit(1);
|
||||
// Throw rather than exit here so the single catch in runApiKeyLogin records
|
||||
// the abandonment (auth_login_failed: aborted) and then exits.
|
||||
throw new Error("Aborted.");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ function offlineEngineLines(engines?: OfflineEngineLine[]): string[] {
|
||||
* so it's left to the docs — not dangled here as a command a fresh machine
|
||||
* can't run. Names the local fallback so "no key" never reads as a failure,
|
||||
* and never steers users toward a per-repo `.env`. Mirrors the
|
||||
* hyperframes-media skill's Preflight section.
|
||||
* media-use skill's Preflight section.
|
||||
*/
|
||||
export function buildUnconfiguredLines(
|
||||
ctx: UnconfiguredContext,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve, sep } from "node:path";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { errorBox } from "../ui/format.js";
|
||||
import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js";
|
||||
import {
|
||||
loadProjectVariableSchema,
|
||||
reportVariableIssues,
|
||||
@@ -90,10 +91,7 @@ function parseJson(raw: string, source: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error: unknown) {
|
||||
throw new BatchRenderInputError(
|
||||
"Invalid JSON in --batch",
|
||||
`${source}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
throw new BatchRenderInputError("Invalid JSON in --batch", `${source}: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +237,7 @@ export function prepareBatchRender(options: PrepareBatchRenderOptions): Prepared
|
||||
} catch (error: unknown) {
|
||||
throw new BatchRenderInputError(
|
||||
"Could not read --batch",
|
||||
`${batchPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`${batchPath}: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -310,10 +308,6 @@ function emitJsonEvent(event: Record<string, unknown>, json: boolean): void {
|
||||
if (json) console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function renderBatchRow(
|
||||
row: PreparedBatchRow,
|
||||
manifest: BatchManifest,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { c } from "../ui/colors.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Find or download Chrome for rendering", "hyperframes browser ensure"],
|
||||
["Purge a stale/partial download and re-download", "hyperframes browser ensure --force"],
|
||||
["Print the Chrome executable path", "hyperframes browser path"],
|
||||
["Remove cached Chrome download", "hyperframes browser clear"],
|
||||
];
|
||||
@@ -19,11 +20,11 @@ import {
|
||||
} from "../browser/manager.js";
|
||||
import { trackBrowserInstall, trackCommandFailure } from "../telemetry/events.js";
|
||||
|
||||
async function runEnsure(): Promise<void> {
|
||||
async function runEnsure(options?: { force?: boolean }): Promise<void> {
|
||||
clack.intro(c.bold("hyperframes browser ensure"));
|
||||
|
||||
// ARM64 Linux: Chrome headless shell is not available.
|
||||
// Try to find system Chromium first, then attempt auto-install via apt.
|
||||
// ARM64 Linux: Chrome headless shell is not available (apt-get/system-only
|
||||
// install flow, no download cache to force a purge of) — --force is a no-op here.
|
||||
if (isLinuxArm()) {
|
||||
const s = clack.spinner();
|
||||
s.start("Linux ARM64 detected — looking for system Chromium...");
|
||||
@@ -61,26 +62,31 @@ async function runEnsure(): Promise<void> {
|
||||
}
|
||||
|
||||
const s = clack.spinner();
|
||||
s.start("Looking for an existing browser...");
|
||||
if (!options?.force) {
|
||||
s.start("Looking for an existing browser...");
|
||||
|
||||
const existing = await findBrowser();
|
||||
if (existing) {
|
||||
s.stop(c.success("Browser found"));
|
||||
console.log();
|
||||
console.log(` ${c.dim("Source:")} ${c.bold(existing.source)}`);
|
||||
console.log(` ${c.dim("Path:")} ${c.bold(existing.executablePath)}`);
|
||||
console.log();
|
||||
clack.outro(c.success("Ready to render."));
|
||||
return;
|
||||
const existing = await findBrowser();
|
||||
if (existing) {
|
||||
s.stop(c.success("Browser found"));
|
||||
console.log();
|
||||
console.log(` ${c.dim("Source:")} ${c.bold(existing.source)}`);
|
||||
console.log(` ${c.dim("Path:")} ${c.bold(existing.executablePath)}`);
|
||||
console.log();
|
||||
clack.outro(c.success("Ready to render."));
|
||||
return;
|
||||
}
|
||||
|
||||
s.stop("No browser found — downloading");
|
||||
} else {
|
||||
s.start("Purging cached download and re-downloading...");
|
||||
}
|
||||
|
||||
s.stop("No browser found — downloading");
|
||||
|
||||
const downloadSpinner = clack.spinner();
|
||||
downloadSpinner.start(`Downloading Chrome Headless Shell ${c.dim("v" + CHROME_VERSION)}...`);
|
||||
|
||||
let lastPct = -1;
|
||||
const result = await ensureBrowser({
|
||||
force: options?.force,
|
||||
onProgress: (downloaded, total) => {
|
||||
if (total <= 0) return;
|
||||
const pct = Math.floor((downloaded / total) * 100);
|
||||
@@ -141,6 +147,12 @@ export default defineCommand({
|
||||
"ensure = find or download Chrome, path = print executable path, clear = remove cached download",
|
||||
required: false,
|
||||
},
|
||||
force: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"ensure only: purge any cached download (including a stale/partial one) and re-download from scratch",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const subcommand = args.subcommand;
|
||||
@@ -157,16 +169,17 @@ ${c.bold("SUBCOMMANDS:")}
|
||||
${c.accent("clear")} ${c.dim("Remove cached Chrome download")}
|
||||
|
||||
${c.bold("EXAMPLES:")}
|
||||
${c.accent("npx hyperframes browser ensure")} ${c.dim("Download Chrome if needed")}
|
||||
${c.accent("npx hyperframes browser path")} ${c.dim("Print path for scripts")}
|
||||
${c.accent("npx hyperframes browser clear")} ${c.dim("Remove cached browser")}
|
||||
${c.accent("npx hyperframes browser ensure")} ${c.dim("Download Chrome if needed")}
|
||||
${c.accent("npx hyperframes browser ensure --force")} ${c.dim("Purge a stale/partial download and re-download")}
|
||||
${c.accent("npx hyperframes browser path")} ${c.dim("Print path for scripts")}
|
||||
${c.accent("npx hyperframes browser clear")} ${c.dim("Remove cached browser")}
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (subcommand) {
|
||||
case "ensure":
|
||||
return runEnsure();
|
||||
return runEnsure({ force: args.force });
|
||||
case "path":
|
||||
return runPath();
|
||||
case "clear":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { resolve } from "node:path";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Capture a website into ./capture/", "hyperframes capture https://stripe.com"],
|
||||
@@ -213,7 +214,7 @@ export default defineCommand({
|
||||
console.log();
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const errMsg = normalizeErrorMessage(err);
|
||||
// Write BLOCKED.md so the user/agent knows the capture failed
|
||||
try {
|
||||
const { mkdirSync, writeFileSync } = await import("node:fs");
|
||||
|
||||
@@ -104,6 +104,11 @@ export interface ManifestEntry {
|
||||
filename: string;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Intrinsic clip resolution (videoWidth/Height). Optional — absent in
|
||||
* manifests written before source dims were recorded. Size off these, not
|
||||
* the display box (width/height). */
|
||||
sourceWidth?: number;
|
||||
sourceHeight?: number;
|
||||
heading: string;
|
||||
caption: string;
|
||||
ariaLabel: string;
|
||||
@@ -213,7 +218,7 @@ export async function runVideoMode(args: VideoModeArgs): Promise<void> {
|
||||
);
|
||||
for (const e of manifest) {
|
||||
console.log(
|
||||
` ${c.bold(`[${e.index}]`)} ${e.filename} — ${e.width}×${e.height}` +
|
||||
` ${c.bold(`[${e.index}]`)} ${e.filename} — ${e.sourceWidth || e.width}×${e.sourceHeight || e.height}` +
|
||||
(e.heading ? `\n heading: "${e.heading}"` : "") +
|
||||
`\n url: ${e.url}`,
|
||||
);
|
||||
@@ -253,7 +258,7 @@ export async function runVideoMode(args: VideoModeArgs): Promise<void> {
|
||||
const relPath = isW2hLayout ? `capture/assets/videos/${fname}` : `assets/videos/${fname}`;
|
||||
|
||||
console.log(
|
||||
`${c.accent("▸")} downloading [${entry.index}] ${entry.filename} (${entry.width}×${entry.height})`,
|
||||
`${c.accent("▸")} downloading [${entry.index}] ${entry.filename} (${entry.sourceWidth || entry.width}×${entry.sourceHeight || entry.height})`,
|
||||
);
|
||||
console.log(` from: ${entry.url}`);
|
||||
try {
|
||||
@@ -264,7 +269,7 @@ export async function runVideoMode(args: VideoModeArgs): Promise<void> {
|
||||
const snippetId = `video-${entry.index}`;
|
||||
console.log(
|
||||
` Reference it from a beat composition as:\n` +
|
||||
` <video id="${snippetId}" src="${relPath}" data-start="0" data-duration="${entry.width === entry.height ? 5 : 4}" data-track-index="0" autoplay muted loop></video>`,
|
||||
` <video id="${snippetId}" src="${relPath}" data-start="0" data-duration="${(entry.sourceWidth || entry.width) === (entry.sourceHeight || entry.height) ? 5 : 4}" data-track-index="0" autoplay muted loop></video>`,
|
||||
);
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user