Merge branch 'fix/studio-stroke-commit-reliability' into fix/studio-ui-audit-fixes

# Conflicts:
#	packages/studio/src/App.tsx
#	packages/studio/src/components/StudioRightPanel.tsx
#	packages/studio/src/components/editor/GestureRecordControl.tsx
#	packages/studio/src/components/editor/PropertyPanel.tsx
#	packages/studio/src/components/editor/propertyPanel3dTransform.tsx
#	packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx
#	packages/studio/src/components/editor/propertyPanelPrimitives.tsx
#	packages/studio/src/components/editor/propertyPanelSections.tsx
#	packages/studio/src/components/editor/propertyPanelStyleSections.tsx
#	packages/studio/src/components/editor/propertyPanelTypes.ts
#	packages/studio/src/components/storyboard/StoryboardView.tsx
#	packages/studio/src/hooks/useFileManager.ts
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-16 03:41:48 -04:00
746 changed files with 53403 additions and 11356 deletions
+18 -1
View File
@@ -9,9 +9,26 @@
{
"name": "hyperframes",
"source": "./",
"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.",
"description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"homepage": "https://hyperframes.heygen.com",
"license": "Apache-2.0"
},
{
"name": "core-skills",
"source": "./",
"description": "The always-needed HyperFrames core set: the /hyperframes router, the hyperframes-* domain skills, and media-use. The full plugin (all skills) is `hyperframes`.",
"homepage": "https://hyperframes.heygen.com",
"license": "Apache-2.0",
"skills": [
"./skills/hyperframes",
"./skills/hyperframes-animation",
"./skills/hyperframes-cli",
"./skills/hyperframes-core",
"./skills/hyperframes-creative",
"./skills/hyperframes-keyframes",
"./skills/hyperframes-registry",
"./skills/media-use"
]
}
]
}
+2 -2
View File
@@ -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.56",
"description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"version": "0.7.60",
"author": {
"name": "HeyGen",
"email": "hyperframes@heygen.com",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "hyperframes",
"version": "0.7.56",
"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.",
"description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"version": "0.7.60",
"author": {
"name": "HeyGen",
"email": "hyperframes@heygen.com",
+2 -2
View File
@@ -2,8 +2,8 @@
"$schema": "https://cursor.com/schemas/cursor-plugin/plugin.json",
"name": "hyperframes",
"displayName": "HyperFrames by HeyGen",
"version": "0.7.56",
"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.",
"description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"version": "0.7.60",
"author": {
"name": "HeyGen",
"email": "hyperframes@heygen.com"
+84 -1
View File
@@ -181,6 +181,13 @@
"file": "packages/producer/src/services/fileServer.ts",
"exports": ["isPathInside"],
},
// `sampledAlphaIsFullyOpaque` is exported for direct unit testing of the
// 1/2/3-frame byte-count gate and per-frame stride logic; the pixel-level
// contract is too load-bearing to only exercise through `webmAlphaAdvisory`.
{
"file": "packages/cli/src/utils/webmAlphaCheck.ts",
"exports": ["sampledAlphaIsFullyOpaque"],
},
// Studio telemetry: consumed by useRenderQueue.ts / StudioFeedbackBar.tsx
// (deep relative imports) but fallow's static analyzer doesn't trace
// them. Same path-resolution quirk — trackStudioSessionStart from the
@@ -211,7 +218,12 @@
// Exported for render.test.ts (exported-for-tests pattern).
{
"file": "packages/cli/src/commands/render.ts",
"exports": ["resolveBrowserGpuForCli", "renderLocal", "checkRenderResolutionPreflight"],
"exports": [
"resolveBrowserGpuForCli",
"renderLocal",
"checkRenderResolutionPreflight",
"renderLintContinuationHint",
],
},
// initThreeDProjectionInPage: passed to page.evaluate() for browser-side execution —
// Puppeteer serializes it at runtime, not an import-graph consumer.
@@ -313,6 +325,28 @@
"file": "packages/core/src/figma/manifest.ts",
"exports": ["mediaDir", "typeDirPath", "isFigmaManifestRecord"],
},
// STUDIO_FLAT_INSPECTOR_ENABLED: exported for use by downstream studio
// inspector redesign tasks; consumed by components in later PRs.
{
"file": "packages/studio/src/components/editor/manualEditingAvailability.ts",
"exports": ["STUDIO_FLAT_INSPECTOR_ENABLED"],
},
// TextAreaField: newly exported for FlatTextSection (flat inspector
// redesign, Task 8), which lands in a later commit on this branch.
{
"file": "packages/studio/src/components/editor/propertyPanelSections.tsx",
"exports": ["TextAreaField"],
},
// Link: its only consumer was FlatRadiusRow's uniform-only fallback row in
// propertyPanelFlatStyleSections.tsx, deleted by the Style parity fix
// (p8-task-style-parity) — that row was unreachable from a uniform radius
// and is now replaced by BorderRadiusEditor's own unlink toggle. Kept in
// the icon set for future reuse rather than deleted from a file outside
// this fix's scope.
{
"file": "packages/studio/src/icons/SystemIcons.tsx",
"exports": ["Link"],
},
],
"ignoreDependencies": [
// Runtime/dynamic deps not visible to static analysis: tsup `external`,
@@ -457,6 +491,10 @@
// 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",
// portUtils.ts contains two pre-existing bounded HTTP probe implementations;
// this PR only teaches the server scan to prefer its reported PID, but that
// line shift makes fallow re-flag the inherited probe clones.
"packages/cli/src/server/portUtils.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
@@ -483,6 +521,43 @@
// scopeRootSelectors handling shifts lines and re-flags both.
"packages/core/src/compiler/inlineSubCompositions.ts",
"packages/core/src/compiler/compositionScoping.test.ts",
// useCanvasZOrderTimelineMirror.test.tsx: independent gesture cases repeat
// the minimal hook/store harness so rapid and rejected z writes cannot
// leak state through a shared fixture.
"packages/studio/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx",
// timelineTimingSync.test.ts: ownership, rollback, transport, and preview
// convergence cases deliberately keep their server stubs and assertions
// local; extracting the repeated arrange/act/assert shape would couple
// otherwise independent failure-domain regressions.
"packages/studio/src/hooks/timelineTimingSync.test.ts",
// useElementLifecycleOps.test.tsx: the reveal and atomic multi-file cases
// repeat a small render/persist harness to keep each transaction's setup,
// durable outcome, and rollback assertions visible together.
"packages/studio/src/hooks/useElementLifecycleOps.test.tsx",
// timelineClipDragCommit.test.ts: drag permutations are self-contained
// arrange/act/assert scenarios; the repeated clip/file fixture makes each
// lane and timing outcome readable without shared mutable setup.
"packages/studio/src/player/components/timelineClipDragCommit.test.ts",
// timelineZMirror.test.ts: mirror acceptance/refusal cases repeat their
// compact lane fixture so each z-to-lane decision remains independently
// understandable and cannot inherit state from another case.
"packages/studio/src/player/components/timelineZMirror.test.ts",
// gsapUndoRestore.test.ts: soft/full restore cases keep the serialized and
// live DOM snapshots beside their assertions; sharing those short fixtures
// would obscure the exact script/identity difference under test.
"packages/studio/src/utils/gsapUndoRestore.test.ts",
// Studio flat-inspector redesign (Plans 2-4): each Flat*Section test file
// repeats the same renderInto/pointerdown-drag/reset-click scaffold as its
// sibling group's tests, added task-by-task across separate PRs on this
// branch. Pre-existing relative to Grade group (Plan 5) work; consistent
// with the norm above of leaving parallel arrange/act/assert test cases
// unabstracted where each case verifies a distinct control's behavior.
"packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx",
"packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx",
"packages/studio/src/components/editor/propertyPanelMediaSection.tsx",
"packages/studio/src/components/editor/PropertyPanel.test.tsx",
"packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx",
"packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx",
],
},
"health": {
@@ -559,6 +634,10 @@
// is pre-existing; the line-shift fingerprint problem makes fallow treat
// the violations as new even though no logic changed.
"packages/cli/src/server/studioServer.ts",
// findPortAndServe's existing port-selection flow predates this PR. The
// preview-lifecycle change only adds PID metadata to the config response,
// which shifts the inherited complexity fingerprint in portUtils.ts.
"packages/cli/src/server/portUtils.ts",
"packages/core/src/core.types.ts",
"packages/core/src/generators/hyperframes.ts",
"packages/producer/src/services/htmlCompiler.ts",
@@ -671,6 +750,10 @@
"packages/core/src/compiler/inlineSubCompositions.ts",
"packages/core/src/compiler/htmlBundler.ts",
"packages/core/src/runtime/compositionLoader.ts",
// TextFieldEditor: pre-existing complexity from earlier Text-inspector
// work on this same branch (commits 444639d75, b57b31beb, 6f2e9848c,
// eba8a0fa2), unrelated to the Grade group (Plan 5) currently landing.
"packages/studio/src/components/editor/propertyPanelSections.tsx",
],
},
}
+3 -4
View File
@@ -10,10 +10,9 @@ This repo ships AI agent skills via [vercel-labs/skills](https://github.com/verc
npx skills add heygen-com/hyperframes
```
**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:
**Creation workflows** route through one entry skill — read `/hyperframes` first: it orients you to the whole surface, confirms the brief up front (the intent layer), 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`.
- `/product-launch-video` — any **website** URL (or a pre-written script / text brief in no-capture mode) → a product launch / promo video, or a site tour / showcase featuring the site's own captured screens; up to ~3 min (sweet spot ~30-90s).
- `/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. For plain captions/subtitles → `/embedded-captions`.
@@ -21,7 +20,7 @@ npx skills add heygen-com/hyperframes
- `/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.
- `/general-video` — fallback for any other video creation (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; 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.
+7 -8
View File
@@ -4,22 +4,21 @@ Open-source video rendering framework: write HTML, render video.
## Skills
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.
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.
```bash
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 --all --full-depth # install all 19 (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).
**`/hyperframes` is the entry skill — read it first.** It's the capability map for the domain skills below, the intent layer that confirms every creation brief up front, 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
- `/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`.
- `/product-launch-video` — any **website** URL (or a pre-written script / text brief in no-capture mode) → a product launch / promo video, or a site tour / showcase featuring the site's own captured screens; up to ~3 min (sweet spot ~30-90s).
- `/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).
- `/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.
- `/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).
@@ -27,7 +26,7 @@ Keep `--full-depth`: it installs the current `main`. Without it, `skills add` fe
- `/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.
- `/general-video` — fallback for any other video creation (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes flow — design → plan → layout → build → validate, any length.
- `/remotion-to-hyperframes` — port an existing Remotion (React) composition to HyperFrames HTML. One-way migration, not creation.
### Domain skills (loaded on demand)
@@ -49,9 +48,9 @@ When adding a new skill, or substantially renaming / repurposing an existing one
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.
3. If the skill changes the routing surface for "make a video" requests, also update the routing table + intent layer in `skills/hyperframes/SKILL.md` AND that workflow's entry in `skills/hyperframes/references/workflow-catalog.md` (the input/output/trigger contract the router reads before the workflow is installed) — plus its row in `references/route-briefs.md` when the route asks questions.
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.
5. 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 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.
+11 -1
View File
@@ -110,4 +110,14 @@ RUN cd packages/producer && bunx tsx scripts/generate-font-data.ts
WORKDIR /app/packages/producer
ENTRYPOINT ["bunx", "tsx", "src/regression-harness.ts", "--", "--sequential"]
# Skip fixtures tagged `transparency` (Chrome alpha-channel PSNR quirks not
# reproducible on the CI image) and `field-signal-reproducer` (known-broken
# fixture per PR #2512 — codifies a real bug awaiting a fix). Mirrors the
# `--exclude-tags` in the local `test:regression*` scripts in
# packages/producer/package.json so `bun run docker:test*` and the aws-lambda
# smoke tests skip the same set. Docker CMD args from `docker run <image> ...`
# are appended after these flags, so positional test names (e.g. shard args
# `hdr-regression style-5-prod ...` in .github/workflows/regression.yml) still
# resolve normally; the harness applies excludeTags whether testNames is empty
# or non-empty (see discoverTestSuites in src/regression-harness.ts).
ENTRYPOINT ["bunx", "tsx", "src/regression-harness.ts", "--", "--sequential", "--exclude-tags", "transparency,field-signal-reproducer"]
+18 -19
View File
@@ -51,33 +51,32 @@ The skills teach agents the HyperFrames production loop: plan the video, write v
## Skills
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.
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…" request — video, deck, or composition port — and points to the domain skills below.
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.
Run `npx skills add heygen-com/hyperframes --full-depth` for the interactive picker — it lists the **core set** under the "Core Skills" group and the on-demand creation workflows under "Other". Use `npx skills add heygen-com/hyperframes --all --full-depth` to install all 19 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.
Installs stay lean after that: `npx hyperframes init` keeps the **core set** fresh (the router, the `hyperframes-*` domain skills, and `media-use` — plus whatever is already installed; `/figma` stays on demand) and never expands a partial install; the creation workflows install **on demand** — the router runs `npx hyperframes skills update <workflow>` before entering one. Nothing re-pulls the full set behind your back.
### Router
| Skill | Use when |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/hyperframes` | **Read first** for any request to make / create / edit / animate / render a video, animation, or motion graphic. Capability map for the domain skills and intent router for the creation workflows below. |
| Skill | Use when |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/hyperframes` | **Read first** for any request to make / create / edit / animate / render a video, animation, or motion graphic. Capability map for the domain skills, the intent layer that confirms every creation brief up front, and intent router for the creation workflows below. |
### Creation workflows
| Skill | Use when |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/product-launch-video` | Marketing / launching / promoting a **product** — from its URL, a brief, or a script (even if the site is only named). Up to ~3 min (sweet spot 30-90s). |
| `/website-to-video` | Turning a **general website** into a video — site tour, portfolio / landing-page showcase, social clip from the site's own visuals. |
| `/faceless-explainer` | **Explaining a topic / concept** from arbitrary text — no product, no URL, no website capture; every visual is LLM-invented (typography / abstract / diagram / data-viz). |
| `/pr-to-video` | A **GitHub pull request** (PR URL, `owner/repo#N` ref, or "this PR") → changelog / feature-reveal / fix / refactor explainer, read via the `gh` CLI. |
| `/embedded-captions` | Adding **captions / subtitles** to an existing talking-head video (footage untouched) — verbatim rail, embedded climax behind the subject, or pure-cinematic embed. |
| `/talking-head-recut` | Packaging an existing talking-head / interview / podcast video with **designed graphic overlays** — lower-thirds, data callouts, kinetic titles, pull-quotes, side panels, PiP. |
| `/motion-graphics` | A short, **unnarrated, design-led motion graphic** (~under 10s) — kinetic type, stat / chart hit, logo sting, lower-third, animated tweet / headline. MP4 or transparent overlay. |
| `/music-to-video` | A **music track** (audio file, or video to pull audio from) → a **beat-synced** video — lyric, slideshow, or kinetic promo; music drives pacing. |
| `/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` | **Anything else** — longer or multi-scene pieces, brand / sizzle reel, title card, static loop, freeform composition. Input- and length-agnostic fallback. |
| `/remotion-to-hyperframes` | **Porting an existing Remotion** (React) composition's source to HyperFrames HTML. One-way migration, not creation. |
| Skill | Use when |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/product-launch-video` | Any **website** — marketing / launching / promoting a product (from its URL, a brief, or a script), or a site tour / showcase / social clip featuring the site's own visuals. Up to ~3 min (sweet spot 30-90s). |
| `/faceless-explainer` | **Explaining a topic / concept** from arbitrary text — no product, no URL, no website capture; every visual is LLM-invented (typography / abstract / diagram / data-viz). |
| `/pr-to-video` | A **GitHub pull request** (PR URL, `owner/repo#N` ref, or "this PR") → changelog / feature-reveal / fix / refactor explainer, read via the `gh` CLI. |
| `/embedded-captions` | Adding **captions / subtitles** to an existing talking-head video (footage untouched) — verbatim rail, embedded climax behind the subject, or pure-cinematic embed. |
| `/talking-head-recut` | Packaging an existing talking-head / interview / podcast video with **designed graphic overlays** — lower-thirds, data callouts, kinetic titles, pull-quotes, side panels, PiP. |
| `/motion-graphics` | A short, **unnarrated, design-led motion graphic** (~under 10s) — kinetic type, stat / chart hit, logo sting, lower-third, animated tweet / headline. MP4 or transparent overlay. |
| `/music-to-video` | A **music track** (audio file, or video to pull audio from) → a **beat-synced** video — lyric, slideshow, or kinetic promo; music drives pacing. |
| `/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` | **Anything else** — longer or multi-scene pieces, brand / sizzle reel, title card, static loop, freeform composition. Input- and length-agnostic fallback, and the home of companion mode (co-create with the full toolbox). |
| `/remotion-to-hyperframes` | **Porting an existing Remotion** (React) composition's source to HyperFrames HTML. One-way migration, not creation. |
### Domain skills (loaded on demand)
@@ -115,7 +114,7 @@ Need ideas? Browse the [Showcase](https://hyperframes.heygen.com/showcase) for f
- PR walkthroughs with animated code diffs, narration, and captions
- Data visualizations, chart races, and map animations
- Social videos with kinetic captions, overlays, and music
- Docs-to-video, PDF-to-video, and website-to-video explainers
- Docs-to-video, PDF-to-video, and site-tour explainers
- Reusable motion graphics for automated content pipelines
## Frame.md
+21 -7
View File
@@ -69,7 +69,7 @@
"fontkit": "^2.0.4",
"giget": "^3.2.0",
"hono": "^4.0.0",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "1.23.2",
"open": "^10.0.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
@@ -1256,6 +1256,8 @@
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"bpm-detective": ["bpm-detective@2.0.5", "", {}, "sha512-FaHFT5WDCR5zwtjVTqxk01o2MDaf22ORHityX1lxMamBcVNX6NWXB6hw7nx4fmlEseRMv0xfuSybINZylljLfA=="],
@@ -1392,6 +1394,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
"devtools-protocol": ["devtools-protocol@0.0.1638949", "", {}, "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA=="],
"dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
@@ -1444,6 +1448,8 @@
"es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="],
"es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -1550,7 +1556,7 @@
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"global-agent": ["global-agent@4.1.3", "", { "dependencies": { "globalthis": "^1.0.2", "matcher": "^4.0.0", "semver": "^7.3.5", "serialize-error": "^8.1.0" } }, "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g=="],
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
"global-directory": ["global-directory@5.0.0", "", { "dependencies": { "ini": "6.0.0" } }, "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w=="],
@@ -1670,6 +1676,8 @@
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
@@ -1732,7 +1740,7 @@
"marked": ["marked@14.1.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg=="],
"matcher": ["matcher@4.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="],
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -1792,9 +1800,9 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onnxruntime-common": ["onnxruntime-common@1.26.0", "", {}, "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw=="],
"onnxruntime-common": ["onnxruntime-common@1.23.2", "", {}, "sha512-5LFsC9Dukzp2WV6kNHYLNzp8sT6V02IubLCbzw2Xd6X5GOlr65gAX6xiJwyi2URJol/s71gaQLC5F2C25AAR2w=="],
"onnxruntime-node": ["onnxruntime-node@1.26.0", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^4.1.3", "onnxruntime-common": "1.26.0" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ=="],
"onnxruntime-node": ["onnxruntime-node@1.23.2", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.23.2" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-OBTsG0W8ddBVOeVVVychpVBS87A9YV5sa2hJ6lc025T97Le+J4v++PwSC4XFs1C62SWyNdof0Mh4KvnZgtt4aw=="],
"open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
@@ -1920,6 +1928,8 @@
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
"rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="],
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
@@ -1936,7 +1946,9 @@
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
"serialize-error": ["serialize-error@8.1.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ=="],
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
"serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
@@ -1968,6 +1980,8 @@
"spawn-command": ["spawn-command@0.0.2", "", {}, "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ=="],
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
@@ -2066,7 +2080,7 @@
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="],
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
+2 -2
View File
@@ -1,11 +1,11 @@
---
title: "Code Highlight Sweep"
description: "A highlight band sweeps across a target line while the surrounding context dims — draws the eye to one line."
description: "A highlight band sweeps across a target line while the surrounding context dims — draws the eye to one line. `line` is 0-based: `line: 1` targets the second displayed line (unlike code-scroll, whose target is 1-based)."
---
# Code Highlight Sweep
A highlight band sweeps across a target line while the surrounding context dims — draws the eye to one line.
A highlight band sweeps across a target line while the surrounding context dims — draws the eye to one line. `line` is 0-based: `line: 1` targets the second displayed line (unlike code-scroll, whose target is 1-based).
`code` `code-animation` `highlight` `developer`
+320
View File
@@ -8,6 +8,326 @@ 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.60"
description="Released - 2026-07-16"
tags={["Release", "Renderer", "Engine", "Studio"]}
>
Rendering is more deterministic across software-GPU, Windows, and long-running worker paths: SwiftShader no longer leaks stale compositor layers into captured frames, video clips can intentionally hold their final frame through a longer composition slot, and capture failures now surface with clearer diagnostics. This release also strengthens Studio file concurrency and SDK/runtime synchronization so edits and previews stay aligned with the underlying project state.
## Features
- **Engine:** Opt-in per-frame timing on fast-capture fallback path ([e6cdf4abb](https://github.com/heygen-com/hyperframes/commit/e6cdf4abb174ea9792a79df83cba3646f140f48e))
- **Engine:** Software-GPU parity diff helper for solid-black capture-shape bugs ([97e094621](https://github.com/heygen-com/hyperframes/commit/97e094621f889c472f5acce760cb3e074f78a67f))
- **Producer:** Calibration-aware heartbeat + worker-death terminal-error contract ([971bcf39a](https://github.com/heygen-com/hyperframes/commit/971bcf39aeb72deda8eb4d161c1c1016eee28f87))
- **Lint:** Flag high overlay-element counts (heavy-capture risk) ([75de927fb](https://github.com/heygen-com/hyperframes/commit/75de927fb52f1e17832ac4d05acc7772ab71dd59))
- **Producer:** Parity telemetry gate for per-clip frame-count invariant ([69b393865](https://github.com/heygen-com/hyperframes/commit/69b393865ae75fff35fd0d105c6ef8ed0bff4303))
- **Engine:** Surface escape hatches in page.goto Nav timeout errors ([58cff5f6d](https://github.com/heygen-com/hyperframes/commit/58cff5f6d5dc8a136617df1a1b1712b142ec0986))
- **Engine:** Auto-disable streaming-encode on Windows software-GPU compound ([cbf2a2ec6](https://github.com/heygen-com/hyperframes/commit/cbf2a2ec69f12f4b5aad384d538d354321ce64cf))
- **Engine:** Surface protocolTimeout env + flag in Puppeteer timeout errors ([6944a1c2d](https://github.com/heygen-com/hyperframes/commit/6944a1c2d0430c8d42c6c5e1408d640b9674a2c8))
- **Core:** ApplyPositionEdits gains force option and undo reset path ([049f72d4d](https://github.com/heygen-com/hyperframes/commit/049f72d4d9e11e86854f0512499aa65a8f5dc012))
- **Sdk:** GetVariableValue(\{ base: true \}) reads pre-override declared defaults ([db5e06221](https://github.com/heygen-com/hyperframes/commit/db5e062211fbad324b67bcc672d4e92cc4e2d351))
## Fixes
- **Renderer:** Prevent stale SwiftShader layers ([54a3ef200](https://github.com/heygen-com/hyperframes/commit/54a3ef2000da635b93c03a41c129a78f0276bf38))
- **Engine:** Emit SystemMemory cgroup notice to stderr, not stdout ([b179c9536](https://github.com/heygen-com/hyperframes/commit/b179c95362645c3ca06fa869d698429e2e3d1e61), [#2520](https://github.com/heygen-com/hyperframes/pull/2520))
- **Studio:** Give flat-inspector value fields a resting-state input affordance ([2b51c5263](https://github.com/heygen-com/hyperframes/commit/2b51c5263414ee9faafbb453f5a9a3ee13af3e43))
- **Studio:** Make setRightPanelTab itself flat-aware, not just the direct tab click ([fb24ecac2](https://github.com/heygen-com/hyperframes/commit/fb24ecac22a766f8a405923551ca51b850487cfc))
- **Video:** Hold final frame through composition ([2e8f871bc](https://github.com/heygen-com/hyperframes/commit/2e8f871bc86d29ec3369f0eb11f0183b2001a07a))
- **Studio:** Show Layers full-height in the flat inspector, not split with Design ([79b688f20](https://github.com/heygen-com/hyperframes/commit/79b688f204d00e85d4dd111e1e35c8d4dc5703ba))
- **Core:** Guard undefined NodeList index in applyPositionEdits loops ([4ac7b4fa8](https://github.com/heygen-com/hyperframes/commit/4ac7b4fa8200ea4250b0370cc5b53ac1002d1efb))
- **Producer:** Propagate --exclude-tags to Dockerfile.test ENTRYPOINT ([1c46eadce](https://github.com/heygen-com/hyperframes/commit/1c46eadceb6b2434a1e245a190057e43035caa98))
- **Skills:** Require actionable CLI feedback repros ([3a71a03de](https://github.com/heygen-com/hyperframes/commit/3a71a03de5037592b3de5f6edafcd76a6415f31a), [#2498](https://github.com/heygen-com/hyperframes/pull/2498))
- **Studio:** Enforce optimistic file concurrency ([2417293da](https://github.com/heygen-com/hyperframes/commit/2417293dabad96bc464495027a751b8aedb6441a), [#2156](https://github.com/heygen-com/hyperframes/pull/2156))
- **Sdk:** AttachSync re-syncs the override snapshot on iframe load ([4682da14f](https://github.com/heygen-com/hyperframes/commit/4682da14f19061aeac25b723e1cbb6a98f5d86ad))
- **CLI:** Align video output boundaries ([35e623b4f](https://github.com/heygen-com/hyperframes/commit/35e623b4f3502a88945750955000f59526a62ed3), [#2490](https://github.com/heygen-com/hyperframes/pull/2490))
- **Skills:** Preserve caption skin contrast states ([f45f76247](https://github.com/heygen-com/hyperframes/commit/f45f762473735befc8cce3407f7dabb6ce8bb4ac), [#2486](https://github.com/heygen-com/hyperframes/pull/2486))
- **CLI:** Preserve snapshot timestamp precision ([9e53c0f9b](https://github.com/heygen-com/hyperframes/commit/9e53c0f9b1da2d11a400d264ffcc41157302925c), [#2494](https://github.com/heygen-com/hyperframes/pull/2494))
- **Runtime:** Resume readiness after deferred GSAP batching ([b874c4440](https://github.com/heygen-com/hyperframes/commit/b874c4440f9fcd4afeeb3a8e616d036d633cba28), [#2491](https://github.com/heygen-com/hyperframes/pull/2491))
- **Engine:** Fail partial audio track preparation ([968c90397](https://github.com/heygen-com/hyperframes/commit/968c90397b3e7852367fa3741c9fc44c3472814d), [#2488](https://github.com/heygen-com/hyperframes/pull/2488))
- **Render:** Ignore favicon probe noise ([850f57ea0](https://github.com/heygen-com/hyperframes/commit/850f57ea04450e21775f9dbd2c39bd45ddf9aa7a), [#2487](https://github.com/heygen-com/hyperframes/pull/2487))
- **Producer:** Bridge stall-timeout env var rename, disambiguate abort from stall on sequential path ([b70d849a4](https://github.com/heygen-com/hyperframes/commit/b70d849a4728ee08d070ac49cd6e6af4b23f4a2e))
- **Producer:** Extend DE stall watchdog to the single-worker streaming path ([650977d1d](https://github.com/heygen-com/hyperframes/commit/650977d1d8a695203047c3020c8a61465ed55bb8))
## Docs & Examples
- **Renderer:** Link SwiftShader workaround tracker ([9c25e27da](https://github.com/heygen-com/hyperframes/commit/9c25e27da64e65e789471e866ec5763097f8053e))
## Internal
- **Producer:** Use intrinsic duration in style fixtures ([4cbde3830](https://github.com/heygen-com/hyperframes/commit/4cbde38301c8453d78ac703ef62519bd0b4227eb))
- **Producer:** Update remaining video slot goldens ([1284703ad](https://github.com/heygen-com/hyperframes/commit/1284703adbe6444a2dc3e873c5a6bdd89b0356d3))
- **Producer:** Update video slot regression golden ([31cc2fe87](https://github.com/heygen-com/hyperframes/commit/31cc2fe875f56d2c79de918f3af0a19042bbd5f1))
- **Producer:** Reproducer fixture for both-escape-hatches-fail case ([e5c4e1970](https://github.com/heygen-com/hyperframes/commit/e5c4e1970cf6cd2b15a54460cd5dd23c50058225))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.59...v0.7.60).
</Update>
<Update
label="HyperFrames v0.7.59"
description="Released - 2026-07-15"
tags={["Release", "Ci", "Registry,skills", "Studio"]}
>
Studio ships a redesigned flat property inspector — single-column, one-open-at-a-time groups for Text, Style, Layout, Motion, Media, and Grade — currently behind the `STUDIO_FLAT_INSPECTOR_ENABLED` flag (off by default) while it finishes validation. Rendering and CLI reliability improve across audio muxing, media discovery, snapshot boundaries, Windows path handling, and browser preflight, and the SDK cutover path is now transactional.
## Features
- **Skills,studio,media Use:** The intent layer, review loop, and user memory — BRIEF.md, companion mode, recipes; /website-to-video folds into /product-launch-video ([b9be0b262](https://github.com/heygen-com/hyperframes/commit/b9be0b26254fe7b759c6310036866f02d9d3d72b), [#2133](https://github.com/heygen-com/hyperframes/pull/2133))
- **Engine,producer,cli:** Capture failing dB/frame index on drawElement verify fallback ([a5cbb78ff](https://github.com/heygen-com/hyperframes/commit/a5cbb78ff96b99f73ad1510acb20f861d1780248))
- **Studio:** Track design-panel input usage across both inspector UIs ([7cc10a992](https://github.com/heygen-com/hyperframes/commit/7cc10a99229e8ff1c625b8659b8aedd724d18b14), [#2467](https://github.com/heygen-com/hyperframes/pull/2467))
- **Studio:** Animate flat inspector group expand/collapse ([a0f2a65bc](https://github.com/heygen-com/hyperframes/commit/a0f2a65bc216cb69a08498bb2eb34aaaf630393d))
- **Studio:** Flatten multi-field Text, retiring TextSection fallback ([0524f1c9c](https://github.com/heygen-com/hyperframes/commit/0524f1c9c2a4a9fe1c4e2c6f6d81be6e9f6d5f5c))
- **Studio:** Add FlatTextLayerList for multi-field text ([b0093835f](https://github.com/heygen-com/hyperframes/commit/b0093835fd728ab91a4b7f922b774a5cf732f653))
- **Studio:** Render pinned groups first with a persisted, per-element-kind pin zone ([b2384fad6](https://github.com/heygen-com/hyperframes/commit/b2384fad67bb16ea0f670b5db43fb5b894a8215a))
- **Studio:** Add PinnedGroupRow always-open pinned-group primitive ([31849d62e](https://github.com/heygen-com/hyperframes/commit/31849d62e2cba67a08b8a4da1c2f496a57f77474))
- **Studio:** Wire the flat Grade group into the one-open accordion ([384fe5934](https://github.com/heygen-com/hyperframes/commit/384fe5934a723b501896732bec319f930e8ef99c))
- **Studio:** Add usePersistedPinnedGroups hook ([4bff97090](https://github.com/heygen-com/hyperframes/commit/4bff97090ba988c74bdfde588c69df1147cb62e5))
- **Studio:** Persist per-element-type pinned groups in studioUiPreferences ([0d7a60a52](https://github.com/heygen-com/hyperframes/commit/0d7a60a5250b703616aadc3751e3df657856fdd5))
- **Studio:** Add HDR warning banner and Apply-to-scope row, completing FlatColorGradingSection ([cc07c84a3](https://github.com/heygen-com/hyperframes/commit/cc07c84a31b78cee1a825d29c41065526837bfee))
- **Studio:** Add Blur and Pixelate effect sliders ([80dc7c49f](https://github.com/heygen-com/hyperframes/commit/80dc7c49f6f01d1883127f98745b0f27ad8fb964))
- **Studio:** Add Vignette and Grain rows with tuned-settings sub-panel ([93f3bf594](https://github.com/heygen-com/hyperframes/commit/93f3bf59452bb959c6028ca6ceb5b0c119e072d8))
- **Studio:** Add the 10 Adjust sliders to FlatColorGradingSection ([96d90feb5](https://github.com/heygen-com/hyperframes/commit/96d90feb50e10bd12a22fd668b856678f803fd9d))
- **Studio:** Add FlatColorGradingSection Preset and Custom LUT rows ([9b6bed282](https://github.com/heygen-com/hyperframes/commit/9b6bed282474fee4ffce7ff704dc319c0863dbfe))
- **Studio:** Add FlatColorGradingAccessory (compare, status dot, reset) ([160193d83](https://github.com/heygen-com/hyperframes/commit/160193d8338596d390c3ca157bada0d1a2c16364))
- **Studio:** Widen FlatSelectRow options to support label!=value entries ([18e4aef7d](https://github.com/heygen-com/hyperframes/commit/18e4aef7dc105519c1de91c64ca5696f4c042eeb))
- **Studio:** Add centerTick and reset-slot to FlatSlider for bipolar grade rows ([afafeccf4](https://github.com/heygen-com/hyperframes/commit/afafeccf4c75e817ef54c9189cdff1e1963d0dbd))
- **Studio:** Wire the flat Media group into the one-open accordion ([5a3abdef3](https://github.com/heygen-com/hyperframes/commit/5a3abdef3650aff2c6600a88aea10a8a1b85d32a))
- **Studio:** Add fit/position rows to FlatMediaSection ([ea463cdf8](https://github.com/heygen-com/hyperframes/commit/ea463cdf8fdc5924bb384ad066214d9f9cb1f7b6))
- **Studio:** Add loop/muted/has-audio-track toggles to FlatMediaSection ([79fdff870](https://github.com/heygen-com/hyperframes/commit/79fdff8701192e8b450e1899739d0719d8c30be1))
- **Studio:** Add volume/rate/media-start sliders to FlatMediaSection ([ab263f25b](https://github.com/heygen-com/hyperframes/commit/ab263f25b4d0ce751a555270566c63120f50a7d6))
- **Studio:** Add cutout sub-block to FlatMediaSection ([138d45faa](https://github.com/heygen-com/hyperframes/commit/138d45faac695fdccf6dfeee5cf94d92b00670eb))
- **Studio:** Add FlatMediaSection scaffold + source/copy row ([0e0cb4846](https://github.com/heygen-com/hyperframes/commit/0e0cb4846d8c8bbc402851d9892b6853c141d04f))
- **Studio:** Add FlatToggle primitive ([14d8de749](https://github.com/heygen-com/hyperframes/commit/14d8de74924d85f6e0b2f2f56d13a2e9575f20b6))
- **Studio:** Wire the flat Motion group into the one-open accordion ([47591f07a](https://github.com/heygen-com/hyperframes/commit/47591f07a6c2b05f7527fec4132e436f8a073451))
- **Studio:** Compose FlatMotionSection from Timing and the flat effect-card list ([9676382bb](https://github.com/heygen-com/hyperframes/commit/9676382bb5eae1279a7036bcf334ebe99c8236f8))
- **Studio:** Add flat collapsed-header variant to AnimationCard ([a3d092ee0](https://github.com/heygen-com/hyperframes/commit/a3d092ee0fa351b88ca866fda27494a3b05f820c))
- **Studio:** Add FlatTimingRow for the flat Motion group ([fb1373905](https://github.com/heygen-com/hyperframes/commit/fb1373905f7ad2b63ef4793ba874ea5a9e841366))
- **Studio:** Wire the flat Layout group into the one-open accordion ([8602e1a8c](https://github.com/heygen-com/hyperframes/commit/8602e1a8c9fcadf64022f0ded0a0d2ecb3845602))
- **Studio:** Compose FlatLayoutSection from geometry, z-index, flex, and 3D blocks ([0369865dd](https://github.com/heygen-com/hyperframes/commit/0369865ddb1ff334fb79708f83d26535ab3dc8cd))
- **Studio:** Nest the existing 3D transform sub-view in a flat wrapper ([5edaf6db7](https://github.com/heygen-com/hyperframes/commit/5edaf6db7ab0c0c2ce16a7292d7e848d314423d8))
- **Studio:** Add flat Layout Z-index row and Flex sub-block ([3b958aeb7](https://github.com/heygen-com/hyperframes/commit/3b958aeb77fbea2ee095dbe65c836ede99f85712))
- **Studio:** Add flat Layout geometry rows (X/Y/W/H/Angle) with keyframe gutters ([588895f31](https://github.com/heygen-com/hyperframes/commit/588895f317c0eaeb00cc303f3c60642c11fdb54f))
- **Studio:** Wire the flat Style group into the one-open accordion ([fea582f2b](https://github.com/heygen-com/hyperframes/commit/fea582f2b4c772919f22e9f927d88b6fae9b5681))
- **Studio:** Add flat Opacity slider, completing the Style group ([ff7a9c593](https://github.com/heygen-com/hyperframes/commit/ff7a9c593a71d1a1ac7c52368973b86a774e7ec3))
- **Studio:** Add flat Overflow and Mask rows to the Style group ([11d1cc749](https://github.com/heygen-com/hyperframes/commit/11d1cc7499a5f83e9e357b8557b2c4d9f26599db))
- **Studio:** Add flat Layer blur and Backdrop sliders to the Style group ([cb8962652](https://github.com/heygen-com/hyperframes/commit/cb8962652b32a0b1aaf103feab5b9a55e742108b))
- **Studio:** Add flat Shadow and Blend rows to the Style group ([988896821](https://github.com/heygen-com/hyperframes/commit/988896821920ad29faf069a32096b14a2508a190))
- **Studio:** Add flat Stroke and Radius rows to the Style group ([b86717d75](https://github.com/heygen-com/hyperframes/commit/b86717d7583259620289e13a17ee46c5c4f8ecc4))
- **Studio:** Add FlatStyleSection with the flat Fill sub-block ([2d9989db3](https://github.com/heygen-com/hyperframes/commit/2d9989db3bc67435bc7930d88b4bb780582e99b9))
- **Studio:** Add stroke-summary format/parse helpers for the flat Style group ([760aa09a4](https://github.com/heygen-com/hyperframes/commit/760aa09a43ce5311417bec6e730ced9e5baea4de))
- **Studio:** Add FlatSelectRow primitive for the flat inspector ([f71fcf4da](https://github.com/heygen-com/hyperframes/commit/f71fcf4da0627010fd97c1a8365347a65b9ac685))
- **Studio:** Add FlatSlider primitive for the flat inspector ([9854c6de7](https://github.com/heygen-com/hyperframes/commit/9854c6de71408615dc31719585d633289fa8daf2))
- **CLI:** Surface stale project pin to non-TTY agents, throttled ([9bcd279b2](https://github.com/heygen-com/hyperframes/commit/9bcd279b2974d1064291ac3cd1095eefb0818fd8))
- **CLI:** Hyperframes upgrade --project bumps pinned package.json scripts ([c1b1c729e](https://github.com/heygen-com/hyperframes/commit/c1b1c729e52d1787f65d591363bd13f5587e0692))
- **CLI:** Add project-pin rewrite helper ([6fec8f39a](https://github.com/heygen-com/hyperframes/commit/6fec8f39ae3eb336648b4c5f5323669c78f43ce9))
- **Studio:** Render the flat Ledger inspector shell behind STUDIO_FLAT_INSPECTOR_ENABLED ([e0066834b](https://github.com/heygen-com/hyperframes/commit/e0066834b7dab192f8718ba2b2a454aa3aa922ec))
- **Studio:** Wire group-selection and hide-all into the property panel ([82f3f340a](https://github.com/heygen-com/hyperframes/commit/82f3f340aa2289466a44e3078e29368b79c9d679))
- **Studio:** Add flat empty and multi-select states (#6b) ([4e2441229](https://github.com/heygen-com/hyperframes/commit/4e2441229fc97313579085d0714c9522255f22a5))
- **Studio:** Add FlatTextSection, the flat inspector's canonical reference group ([b5d11d035](https://github.com/heygen-com/hyperframes/commit/b5d11d0352f0dca95dcc8c9bb226f75d93f78600))
- **Studio:** Add flat trigger variants for ColorField, FontFamilyField, TextAreaField ([2983e7425](https://github.com/heygen-com/hyperframes/commit/2983e742535632d2ca773f5e80bf6ab3c2752dfc))
- **Studio:** Add flat footer (ask agent + record) for the inspector ([633171bb1](https://github.com/heygen-com/hyperframes/commit/633171bb153b15771e86948298e1dc7c98ee1a7b))
- **Studio:** Add flat identity header for the inspector ([015266752](https://github.com/heygen-com/hyperframes/commit/0152667526af025a5be78cd703014344dcb6be87))
- **Studio:** Add FlatGroup accordion and PinnedZoneDivider primitives ([b324a43ba](https://github.com/heygen-com/hyperframes/commit/b324a43ba0e45f7e9781a64b122ce51067c6c5f9))
- **Studio:** Add FlatRow and FlatSegmentedRow flat-inspector primitives ([c1916f1c2](https://github.com/heygen-com/hyperframes/commit/c1916f1c25d54cfa05a4dedd418cb4855cf52342))
- **Studio:** Add 3-tier value/label color resolver for the flat inspector ([797df2a64](https://github.com/heygen-com/hyperframes/commit/797df2a64a6395d8c1fb0c92232cc829608f91c5))
- **Studio:** Add flat-inspector tokens and STUDIO_FLAT_INSPECTOR_ENABLED flag ([3dc11c713](https://github.com/heygen-com/hyperframes/commit/3dc11c71370bc2fa0139f24bd2f61acdeba6ef8a))
## Fixes
- **Ci:** Keep contact sheet test lightweight ([dbdf03cd6](https://github.com/heygen-com/hyperframes/commit/dbdf03cd6b2e049e17bd2d6b2657d78f3ada7d71), [#2492](https://github.com/heygen-com/hyperframes/pull/2492))
- **Studio:** Mute composition hover previews ([e038cc93c](https://github.com/heygen-com/hyperframes/commit/e038cc93c6c805519634c1d628dbadc99b931d8d), [#2478](https://github.com/heygen-com/hyperframes/pull/2478))
- **CLI:** Restore Intel macOS background removal ([04954ead8](https://github.com/heygen-com/hyperframes/commit/04954ead818d5db91efbdd7bbb2bd1e5f07a2f60), [#2480](https://github.com/heygen-com/hyperframes/pull/2480))
- **Render:** Avoid Windows output path limit for work dirs ([882c20324](https://github.com/heygen-com/hyperframes/commit/882c203241b43e6a1515c2672a4285d0d4f5425c), [#2479](https://github.com/heygen-com/hyperframes/pull/2479))
- **Studio:** Make sdk cutover transactional ([42055296e](https://github.com/heygen-com/hyperframes/commit/42055296ee7196b402748cda6ab52e56e55ec07e), [#2155](https://github.com/heygen-com/hyperframes/pull/2155))
- **Skills,cli:** Close four reproduced contract gaps from the CLI feedback digest ([7d21cc9b8](https://github.com/heygen-com/hyperframes/commit/7d21cc9b8ae53139a97f3c03648e48c412070d31), [#2476](https://github.com/heygen-com/hyperframes/pull/2476))
- **Render:** Normalize local AAC duration before mux ([189528618](https://github.com/heygen-com/hyperframes/commit/1895286189635b04b5b68f8b8d3695d418e0ba2d), [#2472](https://github.com/heygen-com/hyperframes/pull/2472))
- **CLI:** Preserve media at snapshot end boundary ([ca69b6b08](https://github.com/heygen-com/hyperframes/commit/ca69b6b0809019b1d835b7abb14f5eed99e96634), [#2475](https://github.com/heygen-com/hyperframes/pull/2475))
- **Render:** Discover runtime-inserted media before extraction ([47e4b18f9](https://github.com/heygen-com/hyperframes/commit/47e4b18f997154e861ff39c2d4dde84ab03089ec), [#2474](https://github.com/heygen-com/hyperframes/pull/2474))
- **CLI:** Keep render filename timestamp local ([017183ad6](https://github.com/heygen-com/hyperframes/commit/017183ad661108ddec3d29ddfd5a698ab58f3db0), [#2470](https://github.com/heygen-com/hyperframes/pull/2470))
- **CLI:** Honor PRODUCER_HEADLESS_SHELL_PATH in findFromEnv ([72d30cda8](https://github.com/heygen-com/hyperframes/commit/72d30cda8d12edc8139e83e5da3304061858262a))
- **CLI:** Avoid multiline overlap false positives ([c54bf13fb](https://github.com/heygen-com/hyperframes/commit/c54bf13fb4644b5d4ce27525788faaf80c12d80b), [#2468](https://github.com/heygen-com/hyperframes/pull/2468))
- **Engine,producer:** Classify blank vs psnr from a structural field, not the error message ([36075c679](https://github.com/heygen-com/hyperframes/commit/36075c6797a710b50706c5405e67205fa9ef4c66))
- **Producer,cli:** Round the fallback dB consistently, thread the verify threshold through ([af923d947](https://github.com/heygen-com/hyperframes/commit/af923d947c28c3306dbd05e0ecc2b9bd4961df72))
- **CLI:** Preflight browser archive extraction ([d2aa4f8bc](https://github.com/heygen-com/hyperframes/commit/d2aa4f8bc4654e2be83df842733b2b77ea98dddf), [#2469](https://github.com/heygen-com/hyperframes/pull/2469))
- **Lint:** Exempt caption cues from track density ([f3800f357](https://github.com/heygen-com/hyperframes/commit/f3800f3579ae210c9229135a80f2a0b2f6e31a91), [#2461](https://github.com/heygen-com/hyperframes/pull/2461))
- **CLI:** Discover project-local ffmpeg binaries ([e3ca89e9a](https://github.com/heygen-com/hyperframes/commit/e3ca89e9ac22986e63320b0340adf14de1c6374b), [#2464](https://github.com/heygen-com/hyperframes/pull/2464))
- **CLI:** Scale whisper timeout for long recordings ([a04ce6a24](https://github.com/heygen-com/hyperframes/commit/a04ce6a24488bd877ed478d1daac600d854536a7), [#2463](https://github.com/heygen-com/hyperframes/pull/2463))
- **Media Use:** Format resolver diagnostics ([bf8432bca](https://github.com/heygen-com/hyperframes/commit/bf8432bca806b67e417477d9ac4e6421b59a0714), [#2466](https://github.com/heygen-com/hyperframes/pull/2466))
- **Producer:** Fail renders when audio mixing fails ([6813eaeec](https://github.com/heygen-com/hyperframes/commit/6813eaeece2f5564a05742036290a64dbf8144a5), [#2429](https://github.com/heygen-com/hyperframes/pull/2429))
- **CLI:** Stream non-TTY render progress ([cada0e7c0](https://github.com/heygen-com/hyperframes/commit/cada0e7c0933562501e947ebedfe62ac4957baf2), [#2458](https://github.com/heygen-com/hyperframes/pull/2458))
- **Capture:** Bound static dedup verification time ([d047d28bb](https://github.com/heygen-com/hyperframes/commit/d047d28bb402170be3d993d316ad92ae1d8e08e3), [#2457](https://github.com/heygen-com/hyperframes/pull/2457))
- **Media Use:** Explain missing bundled SFX ([da19f9a69](https://github.com/heygen-com/hyperframes/commit/da19f9a69267fe5f706b97e13a21ecf6852b4e5b), [#2460](https://github.com/heygen-com/hyperframes/pull/2460))
- **Core:** Consolidate external asset and dependency preservation ([7382fabab](https://github.com/heygen-com/hyperframes/commit/7382fabab9c5b7e3fbb6e0b53a4f8a4561a36852), [#2410](https://github.com/heygen-com/hyperframes/pull/2410))
- **Render:** Consolidate preflight and local recovery ([5d3a7404f](https://github.com/heygen-com/hyperframes/commit/5d3a7404fa4dc0907f11ce90db88e3370ea24e1b), [#2403](https://github.com/heygen-com/hyperframes/pull/2403))
- **Skills:** Bundle modular capture helpers ([15ca6fd12](https://github.com/heygen-com/hyperframes/commit/15ca6fd12954a4bfffdb5ef46624c4dbcbc8078c), [#2456](https://github.com/heygen-com/hyperframes/pull/2456))
- **Lint:** Consolidate lint and audit correctness ([ada878fdc](https://github.com/heygen-com/hyperframes/commit/ada878fdcde967a90da6cf49b05f05e67fabce9e), [#2413](https://github.com/heygen-com/hyperframes/pull/2413))
- **Fonts:** Consolidate composition-aware compilation ([37e1b2643](https://github.com/heygen-com/hyperframes/commit/37e1b26434fbc8f0594c0081d42e48533403040d), [#2406](https://github.com/heygen-com/hyperframes/pull/2406))
- **Studio:** Shrink useDomEditCommits.ts under the file-size gate ([07cfc4c19](https://github.com/heygen-com/hyperframes/commit/07cfc4c191707833a900c7a1fe1d8e590ed2639c))
- **Studio:** Atomic timing pin, expanded-list Hide All, repeated-host matching, pointercancel revert ([e820e1809](https://github.com/heygen-com/hyperframes/commit/e820e18092e6fa96404af38b194f9682f78b6070))
- **Studio:** Cancel FlatSlider drags on Escape and right-click ([28304be74](https://github.com/heygen-com/hyperframes/commit/28304be74a450afc27882e8cb6af05074876dffc))
- **Studio:** Guard slider release reentrancy, scope Grade persist to schedule-time callback ([fc70d5f33](https://github.com/heygen-com/hyperframes/commit/fc70d5f3310b25d7088d351375d1824202814f16))
- **Studio:** Version-scope Grade persist, flush pending edits via effect cleanup not render ([e07f1405f](https://github.com/heygen-com/hyperframes/commit/e07f1405f6be5b9bcf0f38060fb27d6333a43b44))
- **Studio:** Wire Grade rollback through the real commit path, scope async completions ([539e027b6](https://github.com/heygen-com/hyperframes/commit/539e027b605a6ce35ea5462f56d59ec540ed79a1))
- **Studio:** Resolve the 4 cumulative blockers from the #2416 tip re-review ([1deb0dc97](https://github.com/heygen-com/hyperframes/commit/1deb0dc97066edcb56bbc05e8b8269f4fd22a6e1))
- **Studio:** Resolve 8 confirmed adversarial-review findings across the flat-inspector stack ([5dd9efe55](https://github.com/heygen-com/hyperframes/commit/5dd9efe5558667c8d230c9ed37bc3013c544e7fa))
- **Studio:** Resolve flat-inspector review defects ([6062d3b31](https://github.com/heygen-com/hyperframes/commit/6062d3b31c52403d42c625aa69170fbb12ab3018))
- **Studio:** Throttle FlatSlider commits instead of debouncing them ([a1fccfa74](https://github.com/heygen-com/hyperframes/commit/a1fccfa748c2f0d9c9045936af302c48d1ff20bb))
- **Studio:** Support drag on FlatSlider, not just click-to-set ([c69977868](https://github.com/heygen-com/hyperframes/commit/c699778689a02df7830763d48a8aa7d164223bce))
- **Studio:** Debounce flat slider drag commits to prevent rapid-fire writes ([567b0aa01](https://github.com/heygen-com/hyperframes/commit/567b0aa017cee9705a7487e113884a7591d957cb))
- **Studio:** Widen FlatSlider's click/drag hit area vertically ([ce07dfcd4](https://github.com/heygen-com/hyperframes/commit/ce07dfcd4d860a560faf543a6f6980a60796baab))
- **Studio:** Animate the implicitly-closed group too, add regression test ([2aefec3e6](https://github.com/heygen-com/hyperframes/commit/2aefec3e6803a3b71c7db54422343846d718cef9))
- **Studio:** Restore capitalize, end-align, live-commit size, and autofocus in flat Text ([f6fa9b4ec](https://github.com/heygen-com/hyperframes/commit/f6fa9b4ec38341b2ad086722aa545f9645f73a74))
- **Studio:** Restore zero-strength revive, keyboard hold, and HDR detail in flat Grade ([8a25f21cd](https://github.com/heygen-com/hyperframes/commit/8a25f21cd6c917e2af6878d960d9f6aed1d220b8))
- **Studio:** Restore stroke color, radius unlink, and mask inset in flat Style ([58bb05c31](https://github.com/heygen-com/hyperframes/commit/58bb05c31d571386a95df34ad2263c349be5c873))
- **Studio:** Retire legacy Style and Grade sections from flat inspector ([22359eaab](https://github.com/heygen-com/hyperframes/commit/22359eaab38332c2da57ee6a1d89def00162a2da))
- **Studio:** Recompute keyframe currentPct from the corrected timing basis in the flat Layout group ([4b592832a](https://github.com/heygen-com/hyperframes/commit/4b592832ab778ba180e1a36513cfd3c81ab04501))
- **Studio:** Reconcile keyframe-seek timing basis between flat Layout and Motion groups ([b155ed46b](https://github.com/heygen-com/hyperframes/commit/b155ed46b63989e6e77e2186e6ed1bfc184fc200))
- **Engine:** Consolidate capture readiness and retries ([9e2afbcce](https://github.com/heygen-com/hyperframes/commit/9e2afbcce58bd87c6febd552c433609db3b4546f), [#2404](https://github.com/heygen-com/hyperframes/pull/2404))
- **Skills:** Consolidate animation-map capture reliability ([eb731b6a8](https://github.com/heygen-com/hyperframes/commit/eb731b6a8ae731f4367e27dca9c6a32d5db60934), [#2409](https://github.com/heygen-com/hyperframes/pull/2409))
- **CLI:** Bundle preview font localization ([9ce44b660](https://github.com/heygen-com/hyperframes/commit/9ce44b660363a9feba64f3568a663e9a9ccafc80), [#2449](https://github.com/heygen-com/hyperframes/pull/2449))
- **Render:** Consolidate duration and timing correctness ([0b3dfb3f8](https://github.com/heygen-com/hyperframes/commit/0b3dfb3f84dd2bd711a4a2ebfe93822c3969ea1c), [#2405](https://github.com/heygen-com/hyperframes/pull/2405))
- **Product Launch:** Consolidate media brand and audio contracts ([6ac18fd68](https://github.com/heygen-com/hyperframes/commit/6ac18fd68d1ef2f007884d23dfb1d297faec4347), [#2408](https://github.com/heygen-com/hyperframes/pull/2408))
- **Studio:** Apply cross-file rule to all tripwire entry points; harden disk-truth check ([17f3e5beb](https://github.com/heygen-com/hyperframes/commit/17f3e5beb0dffd92ea68b2437f8ffcaf62998d46))
- **CLI:** Surface HYPERFRAMES_BROWSER_PATH hint on pinned browser download failures ([fd02e6efc](https://github.com/heygen-com/hyperframes/commit/fd02e6efc9b9155252c2a3486dbd2d3826d6719e))
- **Studio:** Cross-file tripwire guard + stale-session disk check ([405af8f8b](https://github.com/heygen-com/hyperframes/commit/405af8f8ba1f58aa189db1bd7ff2dd71f3a23d77))
- **Studio:** Gate flat Text group to text-editable elements, dedupe heading in multi-field fallback ([7b5d8c7d4](https://github.com/heygen-com/hyperframes/commit/7b5d8c7d44264ea4345763d1af69a028fb14a1e1))
## Docs & Examples
- **Registry,skills:** Surface code-highlight 0-based indexing and opacity-reveal sweep guidance ([c8d13af9b](https://github.com/heygen-com/hyperframes/commit/c8d13af9b299cf227a83867747fbb166c94c83d5), [#2418](https://github.com/heygen-com/hyperframes/pull/2418))
- **Studio:** Fix stale radius-row comment after unlink fix ([8564ea4c9](https://github.com/heygen-com/hyperframes/commit/8564ea4c92091c9ef6cefa2cc33450b781a5b76d))
- **Templates:** Note how to bump the project's pinned CLI ([e85a50662](https://github.com/heygen-com/hyperframes/commit/e85a50662b62f5c10d3948850694e73ab4f4edfb))
- **Skills:** Instruct agents to bump stale project CLI pins ([499099f1c](https://github.com/heygen-com/hyperframes/commit/499099f1cbd596747a7ee5a431b8dfc2f77c424d))
## Catalog
- **Registry:** Install liquid-glass runtimes ([26eb5956e](https://github.com/heygen-com/hyperframes/commit/26eb5956e5fd63dabafa2d5cc56142e4a688707a), [#2451](https://github.com/heygen-com/hyperframes/pull/2451))
- **Check:** Ignore registry component templates ([14df58f01](https://github.com/heygen-com/hyperframes/commit/14df58f017131140712cda342d341dbeae00cd63), [#2450](https://github.com/heygen-com/hyperframes/pull/2450))
## Internal
- **CLI:** Parameterize browser-install error hint across darwin/win32/linux ([0e3c7269c](https://github.com/heygen-com/hyperframes/commit/0e3c7269cf648d23c11f0f711241f3e8d9ba6c35))
- **Studio:** Remove section pinning from the flat inspector ([2285b399e](https://github.com/heygen-com/hyperframes/commit/2285b399e598edf4b3bb4cefd56cd0882972b460))
- **Studio:** Scroll only the open group's body, keep headers fixed ([528f2be24](https://github.com/heygen-com/hyperframes/commit/528f2be24246e52ae01cb342c7cc43d83a2dedc1))
- **Studio:** Assert stopPropagation prevents onSelect on remove click ([475f764e5](https://github.com/heygen-com/hyperframes/commit/475f764e5a057488000ec3c24fef019ef6a9d80e))
- **Studio:** Rename unpin test to match what it actually verifies ([e6cf07536](https://github.com/heygen-com/hyperframes/commit/e6cf075363ed4b0b1446b22a49d56e05ccadd216))
- **Studio:** Collapse dead-branch scale ternary in Adjust sliders ([82cb7b897](https://github.com/heygen-com/hyperframes/commit/82cb7b8979da9bc4530a185bc08fc869ffba95fd))
- **Studio:** Extract useColorGradingController from ColorGradingSection ([22b3f07c5](https://github.com/heygen-com/hyperframes/commit/22b3f07c56b522a47269451dee5f197108604a18))
## Other Changes
- **Studio:** Distinguish open-section body from header background ([127b8e465](https://github.com/heygen-com/hyperframes/commit/127b8e46585e8de9555cc8b3abe63323795e6728))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.58...v0.7.59).
</Update>
<Update
label="HyperFrames v0.7.58"
description="Released - 2026-07-14"
tags={["Release", "Feedback", "Producer", "Pr To Video"]}
>
HyperFrames now uses a 0–10 recommendation scale for feedback, keeps canvas z-order actions synchronized with timeline lanes, and makes the core workflow set easier to install from the skills picker. Rendering is more reliable across variable-bound and root-absolute media, explicit worker counts, Windows FFmpeg failures, and final artifact publication, while CLI snapshot, frame, layout, and contrast diagnostics are more consistent.
## Features
- **Feedback:** Adopt 0–10 recommendation scale ([990f5c314](https://github.com/heygen-com/hyperframes/commit/990f5c314513a379ccceef050da31a633101c9d1), [#2438](https://github.com/heygen-com/hyperframes/pull/2438))
- **Studio:** Mirror canvas z-order actions into timeline lanes (track order = default paint order) ([89db71889](https://github.com/heygen-com/hyperframes/commit/89db7188994b6d808382cb311dd4d3677a89078d), [#2380](https://github.com/heygen-com/hyperframes/pull/2380))
- **Skills:** Group core skills in the skills add picker ([4a4903b49](https://github.com/heygen-com/hyperframes/commit/4a4903b49a8e1c8cf5ffa91add6c1d9032e79317), [#2412](https://github.com/heygen-com/hyperframes/pull/2412))
## Fixes
- **Render:** Limit automatic missing-libx264 fallback to macOS VideoToolbox; other hardware encoders now require explicit GPU configuration and surface their native FFmpeg error.
- **CLI:** Resolve color-mix() colors in contrast audit instead of false-failing ([6c2513d5c](https://github.com/heygen-com/hyperframes/commit/6c2513d5c80fea57d6686ccddf560e2e8c213afb), [#2445](https://github.com/heygen-com/hyperframes/pull/2445))
- **Producer:** Probe variable-bound media sources ([d2c8c2d80](https://github.com/heygen-com/hyperframes/commit/d2c8c2d8087814f35a3059069efc8c3a7a91cad8), [#2444](https://github.com/heygen-com/hyperframes/pull/2444))
- **Pr To Video:** Use display names, not GitHub logins, in credits narration ([4cba58f5a](https://github.com/heygen-com/hyperframes/commit/4cba58f5a3a269b438d88aa9be406f7961c2fa25), [#2385](https://github.com/heygen-com/hyperframes/pull/2385))
- **Engine:** Honor explicit render worker counts ([e05debe1a](https://github.com/heygen-com/hyperframes/commit/e05debe1afadb802590ee8a58c55f7494a9229f1), [#2439](https://github.com/heygen-com/hyperframes/pull/2439))
- **Render:** Diagnose unlaunchable Windows FFmpeg ([3d7e26aab](https://github.com/heygen-com/hyperframes/commit/3d7e26aabfa34d1a96cc66b7b59cd646711bf216), [#2430](https://github.com/heygen-com/hyperframes/pull/2430))
- **Render:** Publish artifacts atomically ([fb0d82350](https://github.com/heygen-com/hyperframes/commit/fb0d8235008632439967117d4e7f798cfb07e592), [#2154](https://github.com/heygen-com/hyperframes/pull/2154))
- **CLI:** Consolidate snapshot and frame diagnostics ([6933e8acd](https://github.com/heygen-com/hyperframes/commit/6933e8acda57268da9a40e0adf3d99c85059d2b5), [#2402](https://github.com/heygen-com/hyperframes/pull/2402))
- **CLI:** Consolidate layout and contrast audit correctness ([b98463ae3](https://github.com/heygen-com/hyperframes/commit/b98463ae3ae7e278f410ebcae20ce2c3fc82218b), [#2401](https://github.com/heygen-com/hyperframes/pull/2401))
- **Engine:** Resolve root-absolute media from project ([6fc92308d](https://github.com/heygen-com/hyperframes/commit/6fc92308d638a75d753cf160f656e7ec3f9066ea), [#2399](https://github.com/heygen-com/hyperframes/pull/2399))
- **Engine:** Skip unnecessary dimension pad ([0dfc85b68](https://github.com/heygen-com/hyperframes/commit/0dfc85b680aaff20d89dcab4f9d937b8139eb9f5), [#2398](https://github.com/heygen-com/hyperframes/pull/2398))
## Internal
- **Engine:** Make FFmpeg path assertion platform-safe ([d7204ac47](https://github.com/heygen-com/hyperframes/commit/d7204ac47fceff0551de64312ed6affc41892ddc), [#2433](https://github.com/heygen-com/hyperframes/pull/2433))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.57...v0.7.58).
</Update>
<Update
label="HyperFrames v0.7.57"
description="Released - 2026-07-14"
tags={["Release", "Producer", "Release", "CLI"]}
>
`hyperframes publish` now returns a stable public URL: re-publishing updates the same link in place, and a `--space` flag plus a committable project file let a whole team publish to one shared URL. This release also keeps preview review sessions alive across reloads and tags HeyGen API calls with an `X-HeyGen-Client-Source` header.
## Features
- **Preview:** Keep review sessions alive ([2a7a5e623](https://github.com/heygen-com/hyperframes/commit/2a7a5e62361c178daf5f741ee6d0479b3c16c3e9), [#2384](https://github.com/heygen-com/hyperframes/pull/2384))
- **CLI:** Stable public URL for hyperframes publish (re-publish updates the same link) ([4495cb735](https://github.com/heygen-com/hyperframes/commit/4495cb7355bcbb401bcaa04355c817220ef0be48), [#2363](https://github.com/heygen-com/hyperframes/pull/2363))
- **Media Use:** Tag HeyGen calls with X-HeyGen-Client-Source ([dd938c7a1](https://github.com/heygen-com/hyperframes/commit/dd938c7a16dc0200fb46ff2037e07913d299a471), [#2365](https://github.com/heygen-com/hyperframes/pull/2365))
- **CLI:** Tag HeyGen API calls with X-HeyGen-Client-Source ([746b6d5b7](https://github.com/heygen-com/hyperframes/commit/746b6d5b7558220f213c8dae69e4175d3aa6445f), [#2368](https://github.com/heygen-com/hyperframes/pull/2368))
- **CLI:** Coordinate-frame layout findings in check ([7f4eaeb56](https://github.com/heygen-com/hyperframes/commit/7f4eaeb56813640cfb026ff6392d3900dcc12715), [#2354](https://github.com/heygen-com/hyperframes/pull/2354))
## Fixes
- **Release:** Resolve packed-consumer transitive deps to local tarballs ([3dcc6e6a8](https://github.com/heygen-com/hyperframes/commit/3dcc6e6a8021d081aa700f0fbf23e7545a610258), [#2396](https://github.com/heygen-com/hyperframes/pull/2396))
- **CLI:** Require explicit non-interactive init source ([8bf7bc3af](https://github.com/heygen-com/hyperframes/commit/8bf7bc3af5242942ad7b7e8664dc5542fca775b2), [#2395](https://github.com/heygen-com/hyperframes/pull/2395))
- **Engine,producer:** Drive forceScreenshot from one authoritative local ([f44bc3a52](https://github.com/heygen-com/hyperframes/commit/f44bc3a5253af417705b9a373a9f0338a58dae08))
- **Engine:** Carry programmatic forceScreenshot opt-out to concrete-resolved site ([72daac2a1](https://github.com/heygen-com/hyperframes/commit/72daac2a1d6785749e78f838114aa1283037f933))
- **Media Use:** Tag avatar-video heygen recipes with X-HeyGen-Client-Source ([ca7c017e4](https://github.com/heygen-com/hyperframes/commit/ca7c017e4986f07ea9da18db5dc817843bbf0826), [#2391](https://github.com/heygen-com/hyperframes/pull/2391))
- **Pr To Video:** Enforce offline frame contract ([852d1891d](https://github.com/heygen-com/hyperframes/commit/852d1891d3936d3976a66ec88ae8488c23dc1c4c), [#2383](https://github.com/heygen-com/hyperframes/pull/2383))
- **Pr To Video:** Bound first-run workspace and context ([fba5cb9c9](https://github.com/heygen-com/hyperframes/commit/fba5cb9c93e462c06afba79b2705ec5e38dffa71), [#2382](https://github.com/heygen-com/hyperframes/pull/2382))
- **Lint:** Isolate IIFE timing constants ([6f4c6308f](https://github.com/heygen-com/hyperframes/commit/6f4c6308f01b5a4471c56833d291ad9bc2a02950), [#2378](https://github.com/heygen-com/hyperframes/pull/2378))
- **CLI:** Surface keyframes from scripts and styles inside \<template\> ([b231b6f96](https://github.com/heygen-com/hyperframes/commit/b231b6f963895bcef76a046a365404432f4b2c3e), [#2374](https://github.com/heygen-com/hyperframes/pull/2374))
- **Render:** Require HDR transfer metadata ([9639824f4](https://github.com/heygen-com/hyperframes/commit/9639824f4edadd0a3768d68270513e20f0dcd3db), [#2377](https://github.com/heygen-com/hyperframes/pull/2377))
- **CLI:** Reject missing init example values ([3a020bf54](https://github.com/heygen-com/hyperframes/commit/3a020bf543dd97df4790370562f3daec5a9862a4), [#2376](https://github.com/heygen-com/hyperframes/pull/2376))
- **Studio:** Address PR #2347 review findings (rounds 1-2) ([a33b3f35e](https://github.com/heygen-com/hyperframes/commit/a33b3f35e1f4326354857f3e15051207efc6b528))
- **Studio:** Flashless z-order commits and visible-overlap stepping ([760b88a6f](https://github.com/heygen-com/hyperframes/commit/760b88a6f3d75ab492ce7ab4718abfe889c5c1eb))
- **Studio:** Persist canvas z-order actions correctly for static elements ([84963ea8b](https://github.com/heygen-com/hyperframes/commit/84963ea8ba469f25466738294bf8cbeae12bf186))
- **Studio:** Make vertical lane moves persist correctly and harden the z/lane pipeline ([19139b91e](https://github.com/heygen-com/hyperframes/commit/19139b91edafba1d012738f666017c1b9a4e0251))
- **Studio:** Restore golden-branch timeline behaviors dropped by the stack rebuild ([54f41b41b](https://github.com/heygen-com/hyperframes/commit/54f41b41b6ffd63eb0eb862517178f3f8868007b))
- **Engine,producer:** Apply software-GPU screenshot invariant at concrete-resolved point ([2e44602f9](https://github.com/heygen-com/hyperframes/commit/2e44602f99c882ecd050870354dd555becd73182))
- **Render:** Surface structured outcomes ([cb69d3fe0](https://github.com/heygen-com/hyperframes/commit/cb69d3fe00da06168a43b6e23a88e93f8dc449cc), [#2153](https://github.com/heygen-com/hyperframes/pull/2153))
- **CLI:** Skip contrast for intentionally covered text ([8e50e8477](https://github.com/heygen-com/hyperframes/commit/8e50e8477ffc4c266f6ee33371f00bbe460b245b), [#2366](https://github.com/heygen-com/hyperframes/pull/2366))
- **CLI:** Accept 1–3-frame short WebMs + add direct pixel-probe tests ([56dcc4e47](https://github.com/heygen-com/hyperframes/commit/56dcc4e47a2cbd8ce60a0a92e89969ac3cacf746))
- **CLI:** Label the batch asset-import RENDER_FAILED with the images endpoint ([bd8ee05ae](https://github.com/heygen-com/hyperframes/commit/bd8ee05ae29c67f61934c43c1c334864e5f8ac31))
- **Core,cli:** Attribute figma REST failures to the endpoint that failed ([4ca1552e2](https://github.com/heygen-com/hyperframes/commit/4ca1552e20af5244f5d21a63aa4e3c0ed7165077))
- **CLI:** Detect WebM alpha lost when the ALPHA_MODE tag is written without side data ([b8562c91d](https://github.com/heygen-com/hyperframes/commit/b8562c91d5fea2155822739644404556b1051255))
- **Engine:** Software-GPU browsers imply screenshot capture ([ba5168293](https://github.com/heygen-com/hyperframes/commit/ba5168293f8d0db11ff2801814aacd7b8497df94))
- **CLI:** Occlusion-probe false positives — pointer-events blindness, low-alpha gradients, not-yet-entered text ([3e3b37d37](https://github.com/heygen-com/hyperframes/commit/3e3b37d37fb330154480902fe39a4330107c3c98), [#2357](https://github.com/heygen-com/hyperframes/pull/2357))
- **Engine:** Support current FFmpeg filter scripts ([3df59fc0a](https://github.com/heygen-com/hyperframes/commit/3df59fc0a4410b2144362e95473e20c149184722), [#2324](https://github.com/heygen-com/hyperframes/pull/2324))
- **Browser:** Recover abandoned install locks ([81a6edcb6](https://github.com/heygen-com/hyperframes/commit/81a6edcb6734470e2a56b33f7367a4459032bef5), [#2328](https://github.com/heygen-com/hyperframes/pull/2328))
- **Render:** Retry explicit parallel capture timeouts ([5ff4ba13f](https://github.com/heygen-com/hyperframes/commit/5ff4ba13f42516491b19065daa753e9512660037), [#2331](https://github.com/heygen-com/hyperframes/pull/2331))
- **CLI:** Declare Apache-2.0 license ([648e4e8c5](https://github.com/heygen-com/hyperframes/commit/648e4e8c54edf7933ad872e1197194e2904cd939), [#2351](https://github.com/heygen-com/hyperframes/pull/2351))
- **Player:** Own connection and media resources ([16942c0c1](https://github.com/heygen-com/hyperframes/commit/16942c0c12fab7e87876fa253432e29d85835db4))
- **Core:** Preserve runtime transport contract ([cc3ca2f9f](https://github.com/heygen-com/hyperframes/commit/cc3ca2f9f7f42ace95da0f7b1ec03d5c8115f2df))
- **Player:** Version runtime protocol ([dcefdd98c](https://github.com/heygen-com/hyperframes/commit/dcefdd98ca4791cf4090fac78f172746c4a1bd92))
## Performance
- **Producer:** Cache local font compression ([3f6ea0a1a](https://github.com/heygen-com/hyperframes/commit/3f6ea0a1ab3be45a64ae307c8a5af803ac9d0c85), [#2397](https://github.com/heygen-com/hyperframes/pull/2397))
## Docs & Examples
- **Media Use:** Drop the HeyGen generative use-cases table ([6892b6266](https://github.com/heygen-com/hyperframes/commit/6892b62662a65f8421a397fd75ae75bc12027512), [#2370](https://github.com/heygen-com/hyperframes/pull/2370))
- **Skills:** Add cloud render + variables to CLI skill, media-use generative use cases ([78b9a814d](https://github.com/heygen-com/hyperframes/commit/78b9a814d54ffa11f6a5782d7a9fea8e0d073ebd), [#2356](https://github.com/heygen-com/hyperframes/pull/2356))
- **CLI:** Sync SRT upload docs from EF ([74fb4dbcc](https://github.com/heygen-com/hyperframes/commit/74fb4dbcc36f5f5fb65fc8757457c718418bda0b), [#2364](https://github.com/heygen-com/hyperframes/pull/2364))
## Internal
- **Engine:** Write HDR fixture color tags into the H.264 VUI ([9ac4ab8de](https://github.com/heygen-com/hyperframes/commit/9ac4ab8dee6fad5a7a47b0c592567e6e1bd9a4ac), [#2389](https://github.com/heygen-com/hyperframes/pull/2389))
- **Studio:** Delete-path duration rollback coverage, shared dismiss predicate on preview open ([512560b4c](https://github.com/heygen-com/hyperframes/commit/512560b4c3d66f30307252765ab3e8d172bec1cc))
- **Fallow:** Allow sampledAlphaIsFullyOpaque test-only export ([c3b0b91fe](https://github.com/heygen-com/hyperframes/commit/c3b0b91feb6be5759a4217c869cc2975b72d63d6))
- **CLI:** Stabilize flaky install-lock wait-notice test with fake timers ([e47af77c5](https://github.com/heygen-com/hyperframes/commit/e47af77c5a9e43524243833a054e61a5ffbd246e))
- **Repo:** Verify packed consumers ([979e9d090](https://github.com/heygen-com/hyperframes/commit/979e9d0902aa336bd8c1e91a61a3bb0be8bb0ddb))
- **Ci:** Stabilize Windows suites after Studio cutover ([758a6d215](https://github.com/heygen-com/hyperframes/commit/758a6d215289cecea4d3c322b9b85d0b7c7ff1e2), [#2320](https://github.com/heygen-com/hyperframes/pull/2320))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.56...v0.7.57).
</Update>
<Update
label="HyperFrames v0.7.56"
description="Released - 2026-07-13"
+1 -1
View File
@@ -59,7 +59,7 @@ Each capability picks the **first available provider** in order; the last is alw
| **Voice (TTS)** | HeyGen → ElevenLabs → Kokoro | `HEYGEN_API_KEY` → `HYPERFRAMES_API_KEY` → `~/.heygen` · then `ELEVENLABS_API_KEY` | Kokoro: `pip install kokoro-onnx soundfile` |
| **Music (BGM)** | HeyGen library → Lyria → MusicGen | HeyGen credential (above) · then `GEMINI_API_KEY` → `GOOGLE_API_KEY` | MusicGen: `pip install transformers torch soundfile numpy` |
| **Sound effects** | HeyGen library → bundled library | HeyGen credential (above) | bundled — no deps |
| **Capture descriptions** | OpenRouter → Gemini | `OPENROUTER_API_KEY` → `GEMINI_API_KEY` | — (optional; for [website-to-video](/guides/website-to-video)) |
| **Capture descriptions** | OpenRouter → Gemini | `OPENROUTER_API_KEY` → `GEMINI_API_KEY` | — (optional; for [website capture](/guides/website-to-video)) |
Run `npx hyperframes doctor` to check which local dependencies are installed. The media skills also run `hyperframes auth status` as a preflight before generating, so you always know whether a run will use HeyGen or a local engine before it starts.
+18 -10
View File
@@ -3,11 +3,11 @@ title: Feedback Collection
description: "How HyperFrames collects feedback, what data is collected, and how to opt out."
---
HyperFrames occasionally asks how a render went or how a Studio session felt. This page explains why we do it, when prompts appear, what data is collected, and how to disable them.
HyperFrames occasionally asks how likely you are to recommend the render experience or Studio. This page explains why we do it, when prompts appear, what data is collected, and how to disable them.
## Why We Ask
We use anonymous satisfaction scores to understand whether the tool is actually working well — not just whether it runs without errors. A render that takes 10 minutes and produces a broken file counts as a success in logs but a failure in practice. The feedback prompt is the only signal we have for that gap.
We use anonymous recommendation scores to understand whether the tool is actually working well — not just whether it runs without errors. A render that takes 10 minutes and produces a broken file counts as a success in logs but a failure in practice. The feedback prompt is the only signal we have for that gap.
No account, email, or identity is tied to responses. Each installation generates a random UUID at setup; that is the only identifier.
@@ -18,7 +18,7 @@ No account, email, or identity is tied to responses. Each installation generates
After a successful `hyperframes render`, a short prompt may appear:
```
How was this render? [1=poor 5=great, enter to skip]
How likely are you to recommend HyperFrames? [0=not likely 10=extremely likely, enter to skip]
Any details? (enter to skip)
```
@@ -64,15 +64,15 @@ You can submit feedback manually at any time:
```bash
# Quick rating
hyperframes feedback --rating 5
hyperframes feedback --rating 10
# Rating with details
hyperframes feedback --rating 3 --comment "render succeeded but GSAP timeline didn't animate text overlay"
hyperframes feedback --rating 7 --comment "render succeeded but GSAP timeline didn't animate text overlay"
```
| Flag | Description |
|------|-------------|
| `--rating` | Satisfaction score, 1–5 (required) |
| `--rating` | Recommendation score, 0–10 (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) |
@@ -85,7 +85,7 @@ This command collects a doctor summary automatically, flushes telemetry, and exi
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
hyperframes feedback --rating 3 --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:
@@ -101,7 +101,7 @@ The issue is **not auto-submitted**: you review and file it under your own GitHu
When an AI agent is detected, HyperFrames **skips the interactive readline prompt** and prints a structured hint instead:
```
[hyperframes] Agent feedback: hyperframes feedback --rating <1-5> --comment "..."
[hyperframes] Agent feedback: hyperframes feedback --rating <0-10> --comment "..."
```
Agents can then submit feedback using the `hyperframes feedback` command above.
@@ -130,7 +130,8 @@ Only the existence (or in some cases the value) of these variables is checked
| Field | Value |
|-------|-------|
| `$survey_id` | `render_satisfaction` |
| `$survey_response` | Rating (1–5) |
| `$survey_response` | Raw rating (0–10) |
| `rating_scale` | `10` for the current recommendation scale |
| `$survey_response_2` | Free-text comment (only when provided) |
| `render_duration_ms` | Time the render took in milliseconds |
| `doctor_summary` | System context (see below) |
@@ -148,7 +149,8 @@ It may also include `wsl` or sandbox runtime flags when those environments are d
| Field | Value |
|-------|-------|
| `$survey_id` | `studio_experience` |
| `$survey_response` | Rating (1–5) |
| `$survey_response` | Raw rating (0–10) |
| `rating_scale` | `10` for the current recommendation scale |
| `$survey_response_2` | Free-text comment (only when provided) |
| `source` | `studio` |
| `doctor_summary` | Browser context (platform, screen, CPU cores, device memory, network type) |
@@ -163,6 +165,12 @@ It may also include `wsl` or sandbox runtime flags when those environments are d
Feedback is anonymous. Each installation has a random UUID (`anonymousId`) — there is no account, login, or email association.
## Rating Scale Compatibility
New feedback stores the raw integer together with `rating_scale: 10`.
Feedback collected before this change remains on the historical 1–5 satisfaction scale. Historical events have no `rating_scale`, and historical Slack or GitHub records retain their `/5` denominator. Consumers must treat a missing scale as legacy 1–5 data and must not reinterpret those scores as NPS.
## Config File
The CLI persists feedback state in `~/.hyperframes/config.json`:
+3 -3
View File
@@ -5,7 +5,7 @@ description: "The 7-step pipeline for producing any Hyperframes video: capture,
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.
This pipeline is the backbone of the [website-to-video workflow](/guides/website-to-video), but it's just as useful when you're scripting a brand reel from scratch, turning research notes into a launch teaser, or learning Hyperframes for the first time. Most of the production-grade [launch videos](/launch-videos) HeyGen ships are organized this way.
This pipeline is the backbone of the [website-to-video guide](/guides/website-to-video), but it's just as useful when you're scripting a brand reel from scratch, turning research notes into a launch teaser, or learning Hyperframes for the first time. Most of the production-grade [launch videos](/launch-videos) HeyGen ships are organized this way.
## The seven steps
@@ -196,7 +196,7 @@ Each artifact is a checkpoint, so you can stop, hand off to a human reviewer, or
The pipeline is the recommended structure for:
- Capturing a website with the [/website-to-video](/guides/website-to-video) skill, which follows it end-to-end.
- Capturing a website with the [/product-launch-video](/guides/website-to-video) workflow, which follows it end-to-end.
- Shipping a product launch. Most of the [HeyGen launch videos](/launch-videos) use this artifact layout.
- Any narrative video with three or more beats, where a storyboard pays for itself.
- Learning Hyperframes, because the artifacts leave every creative decision inspectable on disk.
@@ -207,7 +207,7 @@ For a 5-second one-shot animation, a single hand-authored composition is fine; t
<CardGroup cols={2}>
<Card title="Website to Video" icon="globe" href="/guides/website-to-video">
The full website-to-video workflow built on this pipeline.
The full website-to-video guide built on this pipeline.
</Card>
<Card title="Prompting" icon="comment" href="/guides/prompting">
How to invoke the pipeline through your AI agent.
+1 -2
View File
@@ -32,8 +32,7 @@ The installer shows a picker. Select the **core skills** below — every project
| Slash command | Input → output |
| ------------------------ | --------------------------------------------------------------------------- |
| `/product-launch-video` | A product URL / brief / script → launch or promo video |
| `/website-to-video` | A general website / URL → a video of the site (tour / showcase / social clip) |
| `/product-launch-video` | Any website URL / brief / script → launch or promo video, or a site tour / showcase |
| `/faceless-explainer` | Arbitrary text (no URL) → faceless explainer with its own TTS narration |
| `/pr-to-video` | A GitHub PR → code-change explainer |
| `/embedded-captions` | An existing talking-head video → the same footage with captions / subtitles |
+5 -6
View File
@@ -7,7 +7,7 @@ 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…" request — usually a video, but also a navigable deck (`/slideshow`) or a composition port (`/remotion-to-hyperframes`).
- **Router** — the entry skill that confirms the brief up front (the intent layer) and 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.
@@ -19,7 +19,7 @@ The skills split into three groups:
npx skills add heygen-com/hyperframes --full-depth
```
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).
Opens a picker so you can choose which skills to add — the core set (the router, the `hyperframes-*` domain skills, and `media-use`) is listed under the "Core Skills" group, the on-demand creation workflows under "Other". 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 everything at once (skip the picker)">
```bash
@@ -68,7 +68,7 @@ The single entry point. Read `/hyperframes` before invoking anything else — it
| Skill | Use when |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/hyperframes` | **Read first** for any request to make / create / edit / animate / render a video, animation, or motion graphic. Capability map for the domain skills and intent router for the creation workflows below. |
| `/hyperframes` | **Read first** for any request to make / create / edit / animate / render a video, animation, or motion graphic. Capability map for the domain skills, the intent layer that confirms every creation brief up front, and intent router for the creation workflows below. |
## Creation workflows
@@ -76,8 +76,7 @@ One workflow per input shape. The router (`/hyperframes`) picks one of these for
| Skill | Use when |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/product-launch-video` | Marketing / launching / promoting a **product** — from its URL, a brief, or a script (even if the site is only named). Up to ~3 min (sweet spot 30-90s). |
| `/website-to-video` | Turning a **general website** into a video — site tour, portfolio / landing-page showcase, social clip from the site's own visuals. |
| `/product-launch-video` | Any **website** — marketing / launching / promoting a product (from its URL, a brief, or a script), or a site tour / showcase / social clip featuring the site's own visuals. Up to ~3 min (sweet spot 30-90s). |
| `/faceless-explainer` | **Explaining a topic / concept** from arbitrary text — no product, no URL, no website capture; every visual is LLM-invented (typography / abstract / diagram / data-viz). |
| `/pr-to-video` | A **GitHub pull request** (PR URL, `owner/repo#N` ref, or "this PR") → changelog / feature-reveal / fix / refactor explainer, read via the `gh` CLI. |
| `/embedded-captions` | Adding **captions / subtitles** to an existing talking-head video (footage untouched) — verbatim rail, embedded climax behind the subject, or pure-cinematic embed. |
@@ -85,7 +84,7 @@ One workflow per input shape. The router (`/hyperframes`) picks one of these for
| `/motion-graphics` | A short, **unnarrated, design-led motion graphic** (~under 10s) — kinetic type, stat / chart hit, logo sting, lower-third, animated tweet / headline. MP4 or transparent overlay. |
| `/music-to-video` | A **music track** (audio file, or video to pull audio from) → a **beat-synced** video — lyric, slideshow, or kinetic promo; music drives pacing. |
| `/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` | **Anything else** — longer or multi-scene pieces, brand / sizzle reel, title card, static loop, freeform composition. Input- and length-agnostic fallback. |
| `/general-video` | **Anything else** — longer or multi-scene pieces, brand / sizzle reel, title card, static loop, freeform composition. Input- and length-agnostic fallback, and the home of companion mode (co-create with the full toolbox). |
| `/remotion-to-hyperframes` | **Porting an existing Remotion** (React) composition's source to HyperFrames HTML. One-way migration, not creation. |
## Domain skills (loaded on demand)
+1 -1
View File
@@ -216,7 +216,7 @@ See the [pipeline guide](/guides/pipeline#iterating) for more re-entry patterns.
npx skills add heygen-com/hyperframes
```
Lead your prompt with _"Use the /website-to-video skill"_ for the most reliable results. Agents also discover it automatically when they see a URL and a video request.
Lead your prompt with _"Use the /product-launch-video skill"_ for the most reliable results — site tours route there too. Agents also discover it automatically when they see a URL and a video request.
</Accordion>
</AccordionGroup>
+1 -1
View File
@@ -19,7 +19,7 @@ Each subdirectory is a standalone HyperFrames project — `index.html`, `composi
| Project | What it shows | Source |
| --- | --- | --- |
| **HyperFrames launch** | The original announcement video — 49.7 s, glass-frame intro, CSS / GSAP / Lottie / shader / Three.js flex section, full narrative arc. | [`hyperframes-launch/`](https://github.com/heygen-com/hyperframes-launches/tree/main/hyperframes-launch) |
| **Website → HyperFrames** | Companion to the [website-to-video](/guides/website-to-video) workflow — captures a site and animates it. | [`website-to-hyperframes/`](https://github.com/heygen-com/hyperframes-launches/tree/main/website-to-hyperframes) |
| **Website → HyperFrames** | Companion to the [website-to-video](/guides/website-to-video) flow — now handled by `/product-launch-video` — captures a site and animates it. | [`website-to-hyperframes/`](https://github.com/heygen-com/hyperframes-launches/tree/main/website-to-hyperframes) |
| **Timeline Editor launch** | 60 fps reveal video for the HyperFrames Timeline Editor: late-SFX cold open, chat-spiral of fine-tune prompts, editor reveal. | [`timeline-launch/`](https://github.com/heygen-com/hyperframes-launches/tree/main/timeline-launch) |
| **Texture launch** | Texture-mask text + shader-driven background, used as a launch teaser. | [`texture-launch-video/`](https://github.com/heygen-com/hyperframes-launches/tree/main/texture-launch-video) |
| **VFX × HeyGen combined** | Multi-act video chaining a VFX text-cursor scene with the HeyGen iPhone canvas test — useful as a reference for combining two existing projects into one render. | [`vfx-heygen-combined/`](https://github.com/heygen-com/hyperframes-launches/tree/main/vfx-heygen-combined) |
+8 -7
View File
@@ -438,7 +438,7 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
The capture command extracts everything an AI agent needs to understand a website's visual identity: viewport screenshots at every scroll depth, color palette (pixel-sampled + DOM computed), font files, images with semantic names, SVGs, Lottie animations, video previews, WebGL shaders, visible text, and page structure.
Output is a self-contained directory with a `CLAUDE.md` file that any AI agent can read to understand the captured site. Used by the `/website-to-video` skill as step 1 of the video production pipeline.
Output is a self-contained directory with a `CLAUDE.md` file that any AI agent can read to understand the captured site. Used by the `/product-launch-video` workflow as step 1 of the video production pipeline.
Set `GEMINI_API_KEY` in a `.env` file for AI-powered image descriptions via Gemini vision (~$0.001/image), or set `OPENROUTER_API_KEY` to use any vision model through [OpenRouter](https://openrouter.ai) instead (takes priority if both are set; override the model with `HYPERFRAMES_OPENROUTER_MODEL`). See the [Website to Video](/guides/website-to-video#enriching-captures-with-gemini-vision) guide for details.
@@ -718,6 +718,7 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
| `--variables-file` | path | — | Path to a JSON file with variable overrides (alternative to `--variables`) |
| `--strict-variables` | — | off | Fail render if any `--variables` key is undeclared or has a wrong type vs the composition's `data-composition-variables`. Without this flag, mismatches print as warnings and the render continues. |
| `--browser-timeout` | seconds (0.001–86400) | 60 | Puppeteer page-navigation timeout for the entry HTML. Increase when heavy compositions (many videos, fonts, or asset requests) cannot reach `domcontentloaded` within the default 60 s. The flag takes **seconds**; the env fallback `PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS` takes **milliseconds**. This controls `page.goto` only — very heavy compositions may also need `PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS` and/or `PRODUCER_PLAYER_READY_TIMEOUT_MS` bumped (post-navigation `window.__hf` readiness has its own 45 s budget). |
| `--protocol-timeout` | milliseconds (≥ 1000) | 300000 (5 min) | Puppeteer CDP protocol timeout — the per-call budget for `Runtime.callFunctionOn` seek/paint, `Page.captureScreenshot`, and other CDP round-trips. Raise on RAM-pressured hosts (≤ 8 GB), heavy-asset compositions (many videos + images), or when the render fails with `Runtime.callFunctionOn timed out` / `Target closed`. The default is auto-scaled per composition by output pixel area (a 4K comp bumps the ceiling proportionally, capped at 30 min); an explicit override sets the floor and disables scaling below it. Env fallback `PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS` (also **milliseconds**). |
CRF and target bitrate default to the `--quality` preset. Use `--crf` or `--video-bitrate` for fine-grained overrides; `RenderConfig.crf` and `RenderConfig.videoBitrate` accept the same overrides programmatically. Use `--video-frame-format png` when source videos are UI recordings, screen captures, or other color-sensitive clips that should avoid JPEG frame extraction.
@@ -900,22 +901,22 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
### `feedback`
Submit anonymous satisfaction feedback about your experience:
Submit anonymous recommendation feedback about your experience:
```bash
# Quick rating (1 = poor, 5 = great)
npx hyperframes feedback --rating 5
# Quick rating (0 = not likely, 10 = extremely likely)
npx hyperframes feedback --rating 10
# Rating with optional details
npx hyperframes feedback --rating 3 --comment "render succeeded but GSAP timeline didn't animate"
npx hyperframes feedback --rating 7 --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
npx hyperframes feedback --rating 3 --comment "GSAP timeline froze on seek" --file-issue
```
| Flag | Description |
|------|-------------|
| `--rating` | Satisfaction score, 1–5 (required) |
| `--rating` | Recommendation score, 0–10 (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) |
+1 -1
View File
@@ -26,7 +26,7 @@ The installer shows a picker. Select the **core set** every project needs — th
| `/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 |
The **workflow skills** are optional — add the ones that match your inputs, and `/hyperframes` routes to whichever you've installed: `/product-launch-video`, `/website-to-video`, `/faceless-explainer`, `/pr-to-video`, `/embedded-captions`, `/talking-head-recut`, `/motion-graphics`, `/music-to-video`, `/slideshow`, `/general-video`, `/remotion-to-hyperframes`. See the [skills catalog](/guides/skills) for the full list with one-line "use when" descriptions.
The **workflow skills** are optional — add the ones that match your inputs, and `/hyperframes` routes to whichever you've installed: `/product-launch-video`, `/faceless-explainer`, `/pr-to-video`, `/embedded-captions`, `/talking-head-recut`, `/motion-graphics`, `/music-to-video`, `/slideshow`, `/general-video`, `/remotion-to-hyperframes`. See the [skills catalog](/guides/skills) for the full list with one-line "use when" descriptions.
<Tip>
To skip the picker and install everything (core + every workflow) in one shot, run `npx skills add heygen-com/hyperframes --all`.
+1 -2
View File
@@ -121,7 +121,7 @@ interface PreviewAdapter {
</ParamField>
<ParamField path="attachSync" type="(comp: Composition) => () => void">
Mirrors a composition's edits onto the adapter's own live document: an immediate full sync of the composition's current overrides, then a subscription that replays every future `patch` event — including undo/redo, since both fire through the same event with forward or inverse patches. Calling `attachSync` again while already attached detaches the previous subscription first. Returns an unsubscribe function.
Mirrors a composition's edits onto the adapter's own live document: an immediate full sync of the composition's current overrides, then a subscription that replays every future `patch` event — including undo/redo, since both fire through the same event with forward or inverse patches. Calling `attachSync` again while already attached detaches the previous subscription first. The full-override sync also re-runs on every iframe `load`, so a `srcdoc` navigation that races the attach (or drops patches committed during the load window) converges once the new document arrives. Returns an unsubscribe function.
Script-tag patches (`/script/gsap` and any future `/script/*` path) are never mirrored — rewriting a live `<script>` tag's content doesn't re-execute it, and re-running GSAP setup from scratch would conflict with running timeline state. Every other patch kind (style, text, attribute, timing, hold, element add/remove, stylesheet, variable value, variable declaration) mirrors as-is.
@@ -335,4 +335,3 @@ preview.commitPreview();
Building a visual editor canvas with the iframe preview adapter and hit-testing.
</Card>
</CardGroup>
+7 -2
View File
@@ -117,7 +117,7 @@ comp.setVariableValue("heroImage", { url: "/assets/hero.jpg", fit: "cover" });
### `getVariableValue`
```typescript
getVariableValue(id: string): string | number | boolean | FontValue | ImageValue | undefined
getVariableValue(id: string, opts?: { base?: boolean }): string | number | boolean | FontValue | ImageValue | undefined
```
Return a declared variable's current `default` value, or `undefined` if the id is undeclared or has no value set.
@@ -126,6 +126,12 @@ Return a declared variable's current `default` value, or `undefined` if the id i
const color = comp.getVariableValue("brandColor"); // "#6C5CE7"
```
Pass `{ base: true }` to read the default **as authored at open** — before the
T3 override-set or any `setVariableValue` folded into the declaration. This is
the value to restore when a host replays an undo that removes a `var.<id>`
override. For a variable declared mid-session, `{ base: true }` returns its
live default (its declaration is its base).
### `listVariables`
```typescript
@@ -753,4 +759,3 @@ comp.dispose();
Template-driven products with host-owned history.
</Card>
</CardGroup>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/aws-lambda",
"version": "0.7.56",
"version": "0.7.60",
"description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.",
"repository": {
"type": "git",
@@ -17,7 +17,8 @@ import { App, Stack } from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions";
import { HyperframesRenderStack } from "./HyperframesRenderStack.js";
// CDK synth is slow on cold start (~5-8s on the slowest CI runner). The
// CDK synth is slow on cold start and reached 32s under full-workspace CI
// contention. The
// default bun:test 5s timeout trips the first `it()` that calls it. Cache
// the default-args synth in `beforeAll` so each test is pure assertions.
// Tests that need non-default props still synth on demand and bump their
@@ -129,9 +130,11 @@ describe("HyperframesRenderStack — contract", () => {
projectName: "demo",
});
const t = Template.fromStack(stack);
t.hasResourceProperties("AWS::Lambda::Function", { FunctionName: "demo-render" });
t.hasResourceProperties("AWS::Lambda::Function", {
FunctionName: "demo-render",
});
t.hasResourceProperties("AWS::StepFunctions::StateMachine", {
StateMachineName: "demo-render",
});
}, 30000);
}, 60000);
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/cli",
"version": "0.7.56",
"version": "0.7.60",
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
"license": "Apache-2.0",
"repository": {
@@ -38,7 +38,7 @@
"fontkit": "^2.0.4",
"giget": "^3.2.0",
"hono": "^4.0.0",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "1.23.2",
"open": "^10.0.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
+5 -1
View File
@@ -3,6 +3,8 @@ import {
AuthClient,
HEYGEN_CLI_SOURCE,
HEYGEN_CLI_SOURCE_HEADER,
HEYGEN_CLIENT_SOURCE,
HEYGEN_CLIENT_SOURCE_HEADER,
apiBaseUrl,
buildAuthHeaders,
} from "./client.js";
@@ -81,12 +83,14 @@ describe("auth/client", () => {
expect(buildAuthHeaders(cred)).toEqual({
authorization: "Bearer at_123",
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
});
});
it("buildAuthHeaders uses x-api-key for api_key, without the cli-source header", () => {
it("buildAuthHeaders uses x-api-key for api_key, without the cli-source header but with the tool tag", () => {
expect(buildAuthHeaders(apiKeyCred())).toEqual({
"x-api-key": "hg_x",
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
});
});
+10 -2
View File
@@ -23,6 +23,12 @@ import type { OAuthTokens } from "./store.js";
const DEFAULT_BASE_URL = "https://api.heygen.com";
export const HEYGEN_CLI_SOURCE_HEADER = "X-HeyGen-Source";
export const HEYGEN_CLI_SOURCE = "cli";
// Tool-attribution header identifying this CLI as the request origin. Sent on
// every HeyGen call (both auth types) so the backend can isolate hyperframes CLI
// usage in billing meta. Distinct from HEYGEN_CLI_SOURCE_HEADER above, which is
// the OAuth-only cli-source header that gates the free allowance.
export const HEYGEN_CLIENT_SOURCE_HEADER = "X-HeyGen-Client-Source";
export const HEYGEN_CLIENT_SOURCE = "hyperframes";
export function apiBaseUrl(): string {
const override = process.env["HEYGEN_API_URL"];
@@ -182,12 +188,14 @@ export function buildAuthHeaders(credential: ResolvedCredential): Record<string,
return {
authorization: `Bearer ${credential.access_token}`,
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
};
}
// API-key traffic keeps the normal billing path; the backend ignores the
// cli-source header for it, so we don't send it (avoids a contradictory
// "cli-source claim on an API-key request").
return { "x-api-key": credential.key };
// "cli-source claim on an API-key request"). The tool-attribution header IS
// sent here — an API-key hyperframes call is still hyperframes usage.
return { "x-api-key": credential.key, [HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE };
}
async function safeText(res: Response): Promise<string> {
@@ -0,0 +1,24 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
describe("background-removal native dependency compatibility", () => {
it("pins onnxruntime-node to the last release with an Intel macOS binary", () => {
const packagePath = fileURLToPath(new URL("../../package.json", import.meta.url));
const packageJson = JSON.parse(readFileSync(packagePath, "utf8")) as {
dependencies?: Record<string, string>;
};
expect(packageJson.dependencies?.["onnxruntime-node"]).toBe("1.23.2");
const bindingEntry = require.resolve("onnxruntime-node");
const packageRoot = join(dirname(bindingEntry), "..");
expect(existsSync(join(packageRoot, "bin/napi-v6/darwin/x64/onnxruntime_binding.node"))).toBe(
true,
);
});
});
+53 -2
View File
@@ -1,12 +1,13 @@
import { execSync } from "node:child_process";
import { execFileSync, 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() }));
vi.mock("node:child_process", () => ({ execFileSync: vi.fn(), execSync: vi.fn() }));
vi.mock("node:fs", () => ({ existsSync: vi.fn() }));
const mockExec = vi.mocked(execSync);
const mockExecFile = vi.mocked(execFileSync);
const mockExists = vi.mocked(existsSync);
// The common-dir fallback list is platform-gated (empty on win32), so pin the
@@ -21,6 +22,7 @@ afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
vi.clearAllMocks();
delete process.env.HYPERFRAMES_FFMPEG_PATH;
delete process.env.HYPERFRAMES_FFPROBE_PATH;
});
describe("findFFmpeg", () => {
@@ -52,4 +54,53 @@ describe("findFFmpeg", () => {
const { findFFmpeg } = await import("./ffmpeg.js");
expect(findFFmpeg()).toBeUndefined();
});
it("finds project-local FFmpeg binaries when they are not on PATH", async () => {
mockExec.mockImplementation(() => {
throw new Error("not found");
});
const localFFmpeg = resolve(".hyperframes", "bin", "ffmpeg");
const localFFprobe = resolve(".hyperframes", "bin", "ffprobe");
mockExists.mockImplementation((path) => path === localFFmpeg || path === localFFprobe);
const { findFFmpeg, findFFprobe } = await import("./ffmpeg.js");
expect(findFFmpeg()).toBe(localFFmpeg);
expect(findFFprobe()).toBe(localFFprobe);
});
});
describe("resolveH264EncoderMode", () => {
it("falls back to VideoToolbox when libx264 is absent", async () => {
const { resolveH264EncoderMode } = await import("./ffmpeg.js");
const encoders = `
V....D h264_videotoolbox VideoToolbox H.264 Encoder
`;
expect(resolveH264EncoderMode(encoders, false)).toBe("gpu");
});
it("does not treat a compiled Linux hardware encoder as usable", async () => {
const { resolveH264EncoderMode } = await import("./ffmpeg.js");
const encoders = `
V....D h264_vaapi H.264/AVC (VAAPI)
`;
expect(() => resolveH264EncoderMode(encoders, false)).toThrow(
"neither libx264 nor VideoToolbox",
);
});
it("inspects the configured FFmpeg binary", async () => {
mockExecFile.mockReturnValue(
" V....D h264_videotoolbox VideoToolbox H.264 Encoder\n" as never,
);
const { detectH264EncoderMode } = await import("./ffmpeg.js");
expect(detectH264EncoderMode("/custom/ffmpeg", false)).toBe("gpu");
expect(mockExecFile).toHaveBeenCalledWith(
"/custom/ffmpeg",
["-hide_banner", "-encoders"],
expect.objectContaining({ encoding: "utf-8" }),
);
});
});
+38 -2
View File
@@ -1,5 +1,5 @@
// fallow-ignore-file code-duplication
import { execSync } from "node:child_process";
import { execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { detectLinuxDistro, ffmpegInstallCommand } from "./linuxDeps.js";
@@ -7,6 +7,34 @@ import { detectLinuxDistro, ffmpegInstallCommand } from "./linuxDeps.js";
export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
export type H264EncoderMode = "software" | "gpu";
/**
* Select the H.264 encoder class supported by an FFmpeg build.
*
* Some macOS FFmpeg distributions expose VideoToolbox but omit libx264. The
* default CPU render path must not pass libx264-only options such as `-preset`
* to those builds.
*/
export function resolveH264EncoderMode(
ffmpegEncodersOutput: string,
gpuRequested: boolean,
): H264EncoderMode {
if (gpuRequested) return "gpu";
if (/\blibx264\b/.test(ffmpegEncodersOutput)) return "software";
if (/\bh264_videotoolbox\b/.test(ffmpegEncodersOutput)) return "gpu";
throw new Error("This FFmpeg build has neither libx264 nor VideoToolbox H.264 encoding.");
}
export function detectH264EncoderMode(ffmpegPath: string, gpuRequested: boolean): H264EncoderMode {
const encoders = execFileSync(ffmpegPath, ["-hide_banner", "-encoders"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 5000,
});
return resolveH264EncoderMode(encoders, gpuRequested);
}
function chooseBestPathCandidate(
name: "ffmpeg" | "ffprobe",
candidates: string[],
@@ -59,13 +87,21 @@ function findInCommonDirs(name: "ffmpeg" | "ffprobe"): string | undefined {
return undefined;
}
function findInProjectLocalBin(name: "ffmpeg" | "ffprobe"): string | undefined {
const extension = process.platform === "win32" ? ".exe" : "";
const candidate = resolve(".hyperframes", "bin", `${name}${extension}`);
return existsSync(candidate) ? candidate : undefined;
}
function findConfiguredBinary(
envName: string,
binaryName: "ffmpeg" | "ffprobe",
): string | undefined {
const configured = process.env[envName]?.trim();
if (configured) return existsSync(configured) ? resolve(configured) : undefined;
return findOnPath(binaryName) ?? findInCommonDirs(binaryName);
return (
findOnPath(binaryName) ?? findInProjectLocalBin(binaryName) ?? findInCommonDirs(binaryName)
);
}
export function findFFmpeg(): string | undefined {
+149
View File
@@ -171,6 +171,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"];
delete process.env["PRODUCER_HEADLESS_SHELL_PATH"];
installChildProcessMocks();
});
@@ -593,6 +594,54 @@ describe("findBrowser — cache resolution", () => {
expect(warnSpy).not.toHaveBeenCalled();
});
// Sibling env-var alias for the CLI resolver. The engine layer already
// honors `PRODUCER_HEADLESS_SHELL_PATH` (see
// `packages/engine/src/services/browserManager.ts`), and docs
// (skills/hyperframes-animation/adapters/typegpu.md,
// packages/gcp-cloud-run/Dockerfile, examples/k8s-jobs/Dockerfile.example)
// all instruct users to set that name. Before this alias, `hyperframes
// check`/`snapshot`/`compare` — which all route through `openSettledCompositionPage`
// → `ensureBrowser` → `findFromEnv` — silently ignored a documented escape
// hatch that `render` had honored, so a user with a broken pinned build
// (win32/x64 STATUS_STACK_BUFFER_OVERRUN 3221225595, #hyperframes-cli-feedback
// ts=1784095034) could render successfully but check would still crash on
// the cached headless-shell. The alias closes that direction of the
// symmetry (the engine side is being closed by #2459).
it("resolves via PRODUCER_HEADLESS_SHELL_PATH when HYPERFRAMES_BROWSER_PATH is unset", async () => {
const directShell = "/opt/chrome-headless-shell/chrome-headless-shell";
installFsMocks({ existing: new Set([directShell]) });
installPuppeteerBrowsersMock();
process.env["PRODUCER_HEADLESS_SHELL_PATH"] = directShell;
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
_resetSystemFallbackWarnForTests();
const result = await findBrowser();
expect(result?.executablePath).toBe(directShell);
expect(result?.source).toBe("env");
expect(warnSpy).not.toHaveBeenCalled();
});
it("prefers HYPERFRAMES_BROWSER_PATH over PRODUCER_HEADLESS_SHELL_PATH when both are set", async () => {
// Tiebreak matches `render.ts` — the CLI-native name canonicalizes; the
// engine name is a compatibility alias. If both are set the caller almost
// certainly meant the CLI-native one.
const hfPath = "/opt/hf/chrome-headless-shell";
const producerPath = "/opt/producer/chrome-headless-shell";
installFsMocks({ existing: new Set([hfPath, producerPath]) });
installPuppeteerBrowsersMock();
process.env["HYPERFRAMES_BROWSER_PATH"] = hfPath;
process.env["PRODUCER_HEADLESS_SHELL_PATH"] = producerPath;
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
_resetSystemFallbackWarnForTests();
const result = await findBrowser();
expect(result?.executablePath).toBe(hfPath);
expect(result?.source).toBe("env");
});
it("does NOT warn on macOS when falling back to system Chrome", async () => {
// macOS Chrome still works fine for the screenshot path and the perf
// claims around BeginFrame are Linux-only — keep the warning Linux-scoped
@@ -697,6 +746,106 @@ describe("installWithCorruptArchiveRecovery", () => {
});
});
// Sibling failure mode to #2078 (SIGTRAP at launch): the field feedback in
// #hyperframes-cli-feedback ts 1784055194.202169 (darwin/arm64, HF CLI 0.7.57)
// hit `All providers failed for chrome-headless-shell 152.0.7928.2` at download
// time and had to discover `HYPERFRAMES_BROWSER_PATH` on their own. The raw
// error propagated straight through `downloadBrowser` without naming the
// escape hatch. This guards the rewrap so the next reporter sees the hint.
//
// Parameterized across all three OS families because `browserPathHintForPlatform`
// branches on `process.platform` and each branch has to survive on its own —
// the field reporter was macOS but the same rewrap is what a Windows or Linux
// (non-ARM) user would see next time providers fail, and each branch names a
// different Chrome install path that has to be spelled correctly.
describe("downloadBrowser — install failure surfaces HYPERFRAMES_BROWSER_PATH hint", () => {
const origPlatform = process.platform;
const origArch = process.arch;
beforeEach(() => {
vi.resetModules();
delete process.env["HYPERFRAMES_BROWSER_PATH"];
delete process.env["PRODUCER_HEADLESS_SHELL_PATH"];
installChildProcessMocks();
});
afterEach(() => {
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
Object.defineProperty(process, "arch", { value: origArch, configurable: true });
vi.restoreAllMocks();
vi.doUnmock("node:fs");
vi.doUnmock("node:os");
vi.doUnmock("node:child_process");
vi.doUnmock("@puppeteer/browsers");
});
// Note: linux/arm64 is deliberately excluded — `downloadBrowser` short-circuits
// into `ensureLinuxArmBrowser` before it ever reaches the install() call this
// suite guards (chrome-headless-shell has no linux-arm64 build; see `isLinuxArm`
// at the top of `downloadBrowser`). Use linux/x64 to exercise the linux branch
// of `browserPathHintForPlatform`.
it.each([
{
label: "darwin/arm64",
platform: "darwin",
arch: "arm64",
expectedPathHint: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
},
{
label: "win32/x64",
platform: "win32",
arch: "x64",
expectedPathHint: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
},
{
label: "linux/x64",
platform: "linux",
arch: "x64",
expectedPathHint: "/usr/bin/google-chrome",
},
])(
"rethrows a non-corrupt install failure with an HYPERFRAMES_BROWSER_PATH hint and preserves the original via cause ($label)",
async ({ platform, arch, expectedPathHint }) => {
Object.defineProperty(process, "platform", { value: platform, configurable: true });
Object.defineProperty(process, "arch", { value: arch, configurable: true });
// No cache, no system Chrome — forces the download-of-last-resort path
// that ends in @puppeteer/browsers install().
installFsMocks({ existing: new Set([CACHE_ROOT]) });
const rawMsg = "All providers failed for chrome-headless-shell 152.0.7928.2";
const originalError = new Error(rawMsg);
installPuppeteerBrowsersMock({
installedInHfCache: [],
installImpl: async () => {
throw originalError;
},
});
const { ensureBrowser } = await import("./manager.js");
let caught: unknown;
try {
await ensureBrowser();
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(Error);
const msg = (caught as Error).message;
// Names the escape-hatch env var by name (that's the entire point).
expect(msg).toContain("HYPERFRAMES_BROWSER_PATH");
// Includes the platform-specific example path from
// `browserPathHintForPlatform`.
expect(msg).toContain(expectedPathHint);
// Keeps the original provider-failure text so the user can still
// diagnose the underlying cause from the surfaced message.
expect(msg).toContain(rawMsg);
// Structured `cause` chain intact for tooling that walks it.
expect((caught as Error).cause).toBe(originalError);
},
);
});
// Regression guard for HF#2103: `hyperframes render` hung forever on macOS
// (Apple Silicon) under Node >= 24.16. Root cause was NOT in this file — it was
// the extractor `@puppeteer/browsers` <3.0.2 shells out to. That chain
+73 -12
View File
@@ -250,8 +250,26 @@ function whichBinary(name: string): string | undefined {
}
}
// Env-var aliases for a caller-supplied browser executable path. The CLI-native
// name `HYPERFRAMES_BROWSER_PATH` is the canonical spelling (documented via the
// download-failure hint added in #2443). `PRODUCER_HEADLESS_SHELL_PATH` is the
// engine-side name that per-worker render launches already honor (see
// `packages/engine/src/services/browserManager.ts` and `render.ts` which even
// propagates the CLI-resolved executable into it). Docs in
// `skills/hyperframes-animation/adapters/typegpu.md`,
// `packages/gcp-cloud-run/Dockerfile`, and `examples/k8s-jobs/Dockerfile.example`
// all instruct users to set `PRODUCER_HEADLESS_SHELL_PATH`, so field reports
// (e.g. `#hyperframes-cli-feedback` ts=1784095034 on win32/x64) hit the case
// where `render` completes via that env var while `check`, `snapshot`, and
// `compare` all ignore it and crash on the cached headless-shell instead.
// Alias them here so every consumer of `openSettledCompositionPage` (which
// calls `ensureBrowser` → `findFromEnv`) picks up the same escape hatch.
// Tiebreak: HYPERFRAMES_BROWSER_PATH wins when both are set, matching the
// CLI-native canonicalization in `render.ts` (which only sets
// `PRODUCER_HEADLESS_SHELL_PATH` if not already present).
function findFromEnv(): BrowserResult | undefined {
const envPath = process.env["HYPERFRAMES_BROWSER_PATH"];
const envPath =
process.env["HYPERFRAMES_BROWSER_PATH"] ?? process.env["PRODUCER_HEADLESS_SHELL_PATH"];
if (envPath && existsSync(envPath)) {
return { executablePath: envPath, source: "env" };
}
@@ -640,6 +658,44 @@ export async function installWithCorruptArchiveRecovery<T>(
}
}
/**
* When `@puppeteer/browsers`' install() rejects for any reason the corrupt-
* archive recovery path can't handle (all CDN providers rejected — the
* `All providers failed for chrome-headless-shell <ver>` case reported from the
* field on darwin/arm64 with CLI 0.7.57; DNS/network failure; a macOS Gatekeeper
* quarantine that blocks the pinned Dev-channel binary from launching a probe;
* a second corruption that trips the retry gate), the raw error names none of
* the escape hatches that would unblock the user. Rewrap it in one that does:
* `HYPERFRAMES_BROWSER_PATH` wins over both the managed download and system
* lookup (see `findFromEnv` above), so pointing it at an already-installed
* Chrome renders successfully via the screenshot fallback while the pinned
* chrome-headless-shell download is broken. Sibling failure mode: #2078
* (SIGTRAP at launch), same remediation, different trigger.
*/
function browserPathHintForPlatform(): string {
if (process.platform === "darwin") {
return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
}
if (process.platform === "win32") {
return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
}
return "/usr/bin/google-chrome";
}
function wrapDownloadFailureWithBrowserPathHint(cause: unknown): Error {
const original = normalizeErrorMessage(cause);
const example = browserPathHintForPlatform();
const message =
`Failed to download chrome-headless-shell ${CHROME_VERSION}: ${original}\n\n` +
`Point hyperframes at an already-installed Chrome/Chromium instead:\n\n` +
` export HYPERFRAMES_BROWSER_PATH="${example}"\n\n` +
`Then re-run your command. Any Chrome build works for the screenshot ` +
`capture path; install a real chrome-headless-shell later if you need the ` +
`perf-optimized BeginFrame path. Alternatively, run inside the hyperframes ` +
`Docker image which ships a compatible headless-shell.`;
return new Error(message, { cause: cause instanceof Error ? cause : undefined });
}
async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
if (isLinuxArm()) {
return ensureLinuxArmBrowser(options);
@@ -661,17 +717,22 @@ async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserR
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.`,
),
);
let installed;
try {
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.`,
),
);
} catch (err) {
throw wrapDownloadFailureWithBrowserPathHint(err);
}
return { executablePath: installed.executablePath, source: "download" };
}
+44 -1
View File
@@ -1,9 +1,16 @@
// fallow-ignore-file code-duplication
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseToolVersion, runEnvironmentChecks } from "./preflight.js";
import { checkDisk, parseToolVersion, runEnvironmentChecks } from "./preflight.js";
import * as manager from "./manager.js";
import * as linuxDeps from "./linuxDeps.js";
const execFileSync = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
execFileSync,
}));
describe("runEnvironmentChecks", () => {
const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH;
@@ -11,6 +18,7 @@ describe("runEnvironmentChecks", () => {
beforeEach(() => {
process.env.HYPERFRAMES_FFMPEG_PATH = process.execPath;
process.env.HYPERFRAMES_FFPROBE_PATH = process.execPath;
execFileSync.mockReturnValue("ffmpeg version 7.1.1\n");
});
afterEach(() => {
@@ -57,6 +65,29 @@ describe("runEnvironmentChecks", () => {
});
});
it("blocks rendering when the selected FFmpeg binary cannot launch", async () => {
execFileSync.mockImplementation((binaryPath: string) => {
if (binaryPath !== process.env.HYPERFRAMES_FFMPEG_PATH) return "ffprobe version 7.1.1\n";
throw Object.assign(new Error("Command failed with exit code 3221225781"), {
status: 3221225781,
});
});
const result = await runEnvironmentChecks();
const ffmpeg = result.outcomes.find((outcome) => outcome.name === "FFmpeg");
expect(ffmpeg).toMatchObject({
ok: false,
level: "error",
title: "FFmpeg cannot start",
path: process.execPath,
});
expect(ffmpeg?.detail).toContain(process.execPath);
expect(ffmpeg?.detail).toContain("3221225781");
expect(ffmpeg?.hint).toContain("working 64-bit FFmpeg build");
expect(result.ffmpegPath).toBeUndefined();
});
it("validates an explicit browser path without needing browser discovery", async () => {
const result = await runEnvironmentChecks({
includeBrowser: true,
@@ -187,3 +218,15 @@ describe("parseToolVersion", () => {
);
});
});
describe("checkDisk", () => {
it("checks the requested render volume", () => {
const freeDiskMb = vi.fn(() => 512);
expect(checkDisk("/external/render-output", freeDiskMb)).toMatchObject({
ok: false,
detail: "0.5 GB free at /external/render-output",
});
expect(freeDiskMb).toHaveBeenCalledWith("/external/render-output");
});
});
+39 -12
View File
@@ -38,6 +38,7 @@ export interface EnvironmentCheckResult {
export interface EnvironmentCheckOptions {
projectDir?: string;
diskPaths?: string[];
browserPath?: string;
includeBrowser?: boolean;
includeDisk?: boolean;
@@ -55,15 +56,23 @@ function configuredMissingDetail(envName: string): string | undefined {
return `Configured path does not exist: ${envName}="${configured}"`;
}
function readToolVersion(binaryPath: string): string {
type ToolVersionResult = { ok: true; detail: string } | { ok: false; detail: string };
function readToolVersion(binaryPath: string): ToolVersionResult {
try {
const raw =
execFileSync(binaryPath, ["-version"], { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ??
"";
const version = parseToolVersion(raw);
return version ? `${version} at ${binaryPath}` : binaryPath;
} catch {
return binaryPath;
return { ok: true, detail: version ? `${version} at ${binaryPath}` : binaryPath };
} catch (error) {
const status =
typeof error === "object" && error !== null && "status" in error ? error.status : undefined;
const exitDetail = typeof status === "number" ? ` (exit code ${status})` : "";
return {
ok: false,
detail: `Failed to run "${binaryPath}" -version${exitDetail}.`,
};
}
}
@@ -82,7 +91,19 @@ function checkFFmpeg(): EnvironmentCheckOutcome {
const path = findFFmpeg();
if (path) {
return { name: "FFmpeg", ok: true, level: "ok", detail: readToolVersion(path), path };
const version = readToolVersion(path);
if (!version.ok) {
return {
name: "FFmpeg",
ok: false,
level: "error",
title: "FFmpeg cannot start",
detail: version.detail,
hint: "Install a working 64-bit FFmpeg build with all required runtime DLLs.",
path,
};
}
return { name: "FFmpeg", ok: true, level: "ok", detail: version.detail, path };
}
return {
@@ -110,7 +131,8 @@ function checkFFprobe(): EnvironmentCheckOutcome {
const path = findFFprobe();
if (path) {
return { name: "FFprobe", ok: true, level: "ok", detail: readToolVersion(path), path };
const version = readToolVersion(path);
return { name: "FFprobe", ok: true, level: "ok", detail: version.detail, path };
}
return {
@@ -205,8 +227,11 @@ async function checkChrome(browserPath?: string): Promise<EnvironmentCheckOutcom
};
}
function checkDisk(projectDir = "."): EnvironmentCheckOutcome {
const freeMb = getFreeDiskMb(projectDir);
export function checkDisk(
path = ".",
freeDiskMb: (path: string) => number | null = getFreeDiskMb,
): EnvironmentCheckOutcome {
const freeMb = freeDiskMb(path);
if (freeMb === null) {
return { name: "Disk", ok: true, level: "ok", detail: "Unable to check" };
}
@@ -217,11 +242,11 @@ function checkDisk(projectDir = "."): EnvironmentCheckOutcome {
ok: false,
level: "error",
title: "Low disk space",
detail: `${freeGb} GB free`,
detail: `${freeGb} GB free at ${path}`,
hint: "Renders produce large temp files. Free disk space before rendering.",
};
}
return { name: "Disk", ok: true, level: "ok", detail: `${freeGb} GB free` };
return { name: "Disk", ok: true, level: "ok", detail: `${freeGb} GB free at ${path}` };
}
function checkWindowsUncPath(projectDir = process.cwd()): EnvironmentCheckOutcome | undefined {
@@ -236,6 +261,7 @@ function checkWindowsUncPath(projectDir = process.cwd()): EnvironmentCheckOutcom
};
}
// fallow-ignore-next-line complexity
export async function runEnvironmentChecks(
options: EnvironmentCheckOptions = {},
): Promise<EnvironmentCheckResult> {
@@ -260,7 +286,8 @@ export async function runEnvironmentChecks(
}
if (options.includeDisk) {
outcomes.push(checkDisk(options.projectDir));
const diskPaths = [...new Set(options.diskPaths ?? [options.projectDir ?? "."])];
outcomes.push(...diskPaths.map((path) => checkDisk(path)));
}
if (options.includeWindowsUnc) {
@@ -270,7 +297,7 @@ export async function runEnvironmentChecks(
return {
outcomes,
...(ffmpeg.path ? { ffmpegPath: ffmpeg.path } : {}),
...(ffmpeg.ok && ffmpeg.path ? { ffmpegPath: ffmpeg.path } : {}),
...(ffprobe.path ? { ffprobePath: ffprobe.path } : {}),
...(browser ? { browser } : {}),
};
@@ -7,7 +7,7 @@
*
* This file generates a DATA INVENTORY that tells the AI agent what files
* exist and what they contain. The actual workflow lives in the
* website-to-video skill — this file points agents there.
* product-launch-video skill — this file points agents there.
*/
import { writeFileSync, readdirSync, existsSync } from "node:fs";
@@ -198,7 +198,7 @@ function buildPrompt(
Source: ${url}
To create a video from this capture, use the \`website-to-video\` skill.
To create a video from this capture, use the \`product-launch-video\` skill.
## What's in This Capture
@@ -305,7 +305,11 @@ describe("captureRegionCrop", () => {
const buffer = await captureRegionCrop(page, region, 3);
expect(setViewport).toHaveBeenNthCalledWith(1, { ...original, deviceScaleFactor: 3 });
expect(screenshot).toHaveBeenCalledWith({ clip: region, type: "png" });
expect(screenshot).toHaveBeenCalledWith({
clip: region,
type: "png",
omitBackground: true,
});
expect(setViewport).toHaveBeenNthCalledWith(2, original);
expect(buffer).toBeInstanceOf(Buffer);
expect(Array.from(buffer)).toEqual([1, 2, 3]);
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
import type { Browser, Page } from "puppeteer-core";
import { c } from "../ui/colors.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
import { resolveDiagnosticNavigationTimeoutMs } from "../utils/renderArgs.js";
const SHADER_TRANSITIONS_TIMEOUT_MS = 90_000;
const CAPTURE_SETTLE_MS = 1500;
@@ -171,7 +172,10 @@ export async function openSettledCompositionPage(
await installPageFunctionGuard(page);
await page.setViewport(viewport);
await options.beforeNavigate?.(page);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.goto(url, {
waitUntil: "domcontentloaded",
timeout: resolveDiagnosticNavigationTimeoutMs(),
});
const renderReadyTimedOut = !(await waitForCompositionSettle(page, options));
return { browser: chromeBrowser, page, renderReadyTimedOut };
} catch (err) {
@@ -446,7 +450,7 @@ export interface CropCapturePage {
height: number;
deviceScaleFactor?: number;
}): Promise<void>;
screenshot(options: { clip: CropRegion; type: "png" }): Promise<Uint8Array>;
screenshot(options: { clip: CropRegion; type: "png"; omitBackground: true }): Promise<Uint8Array>;
}
/**
@@ -465,7 +469,7 @@ export async function captureRegionCrop(
const original = page.viewport();
if (original) await page.setViewport({ ...original, deviceScaleFactor: scale });
try {
const shot = await page.screenshot({ clip: region, type: "png" });
const shot = await page.screenshot({ clip: region, type: "png", omitBackground: true });
return Buffer.isBuffer(shot) ? shot : Buffer.from(shot);
} finally {
if (original) await page.setViewport(original);
@@ -39,6 +39,7 @@ describe("createContactSheet", () => {
await createContactSheet([a, b], out, {
cols: 2,
cellWidth: 16,
labelMode: "custom",
labels: ["A", "B"],
maxImages: 2,
+8
View File
@@ -27,6 +27,14 @@ describe("CLI command registration", () => {
);
});
it("shows the check command used by workflow capability preflight in root help", () => {
const loaders = commandLoaderBlock();
expect(loaders).toMatch(/\bcheck:\s*\(\)\s*=>\s*import\("\.\/commands\/check\.js"\)/);
expect(helpSource).toContain(
'["check", "Run lint, runtime validation, and layout inspection as one gate"]',
);
});
// A command actively reconciling skills (`skills check`/`skills update`)
// must not also nudge the user to go reconcile skills — that nudge is
// either redundant (it just ran) or misleading (a stale cached count from
+4
View File
@@ -206,6 +206,7 @@ let _trackCommandResult:
}) => void)
| undefined;
let _printUpdateNotice: (() => void) | undefined;
let _printStalePinNotice: (() => void) | undefined;
let _printSkillsUpdateNotice: (() => void) | undefined;
// `events` is a telemetry-internal beacon: it self-tracks + self-flushes, so it
@@ -245,6 +246,7 @@ if (
import("./utils/updateCheck.js").then(async (mod) => {
_printUpdateNotice = mod.printUpdateNotice;
_printStalePinNotice = mod.printStalePinNotice;
const result = await mod.checkForUpdate().catch(() => null);
if (result?.updateAvailable) {
const auto = await import("./utils/autoUpdate.js").catch(() => null);
@@ -268,10 +270,12 @@ const runId = getRunId();
// work — so a plain `on` listener would print the update notice (and
// re-flush) once per drain (the user-reported double-print). `once`
// detaches after first invocation, which is what we want for both.
// fallow-ignore-next-line complexity
process.once("beforeExit", () => {
_flush?.().catch(() => {});
if (!hasJsonFlag) {
_printUpdateNotice?.();
_printStalePinNotice?.();
_printSkillsUpdateNotice?.();
}
});
+41 -1
View File
@@ -1,6 +1,46 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, beforeEach } from "vitest";
import { ensureDOMParser } from "../utils/dom.js";
import { parseSubComposition } from "./compositions.js";
import { parseCompositions, parseSubComposition } from "./compositions.js";
describe("parseCompositions", () => {
beforeEach(() => {
ensureDOMParser();
});
it("resolves relative sub-composition starts when computing host duration", () => {
const baseDir = mkdtempSync(join(tmpdir(), "hyperframes-compositions-"));
try {
const compositionsDir = join(baseDir, "compositions");
mkdirSync(compositionsDir);
const subCompositionHtml = `
<template id="scene-template">
<div data-composition-id="scene" data-width="1920" data-height="1080" data-duration="3">
<div class="clip" data-start="0" data-duration="3"></div>
</div>
</template>`;
writeFileSync(join(compositionsDir, "scene.html"), subCompositionHtml);
const html = `
<div data-composition-id="host" data-width="1920" data-height="1080">
<div id="s1" data-composition-id="s1" data-composition-src="compositions/scene.html" data-start="0" data-duration="3" data-track="main"></div>
<div data-composition-id="s2" data-composition-src="compositions/scene.html" data-start="s1" data-duration="3" data-track="main"></div>
</div>`;
const host = parseCompositions(html, baseDir).find(
(composition) => composition.id === "host",
);
expect(host?.duration).toBe(6);
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});
});
describe("parseSubComposition", () => {
beforeEach(() => {
+72 -3
View File
@@ -1,4 +1,5 @@
import { defineCommand } from "citty";
import { parseNumeric, parseStartExpression } from "@hyperframes/core";
import type { Example } from "./_examples.js";
import { existsSync, readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
@@ -45,12 +46,78 @@ function estimateDurationFromScripts(root: ParentNode): number {
return duration;
}
function parseCompositions(html: string, baseDir: string): CompositionInfo[] {
function findReferenceTargetEl(doc: Document, refId: string): Element | null {
return doc.getElementById(refId) ?? doc.querySelector(`[data-composition-id="${refId}"]`);
}
function resolveStart(
doc: Document,
el: Element,
startCache: Map<Element, number>,
visiting: Set<Element>,
): number {
const cached = startCache.get(el);
if (cached !== undefined) return cached;
if (visiting.has(el)) return 0;
visiting.add(el);
try {
const expression = parseStartExpression(el.getAttribute("data-start"));
if (!expression) {
startCache.set(el, 0);
return 0;
}
if (expression.kind === "absolute") {
const value = Math.max(0, expression.value);
startCache.set(el, value);
return value;
}
const target = findReferenceTargetEl(doc, expression.refId);
if (!target) {
startCache.set(el, 0);
return 0;
}
const targetStart = resolveStart(doc, target, startCache, visiting);
const targetDuration = resolveReferencedDuration(doc, target, startCache, visiting);
const resolved =
targetDuration != null && targetDuration > 0
? Math.max(0, targetStart + targetDuration + expression.offset)
: Math.max(0, targetStart + expression.offset);
startCache.set(el, resolved);
return resolved;
} finally {
visiting.delete(el);
}
}
function resolveReferencedDuration(
doc: Document,
el: Element,
startCache: Map<Element, number>,
visiting: Set<Element>,
): number | null {
const durationAttr = parseNumeric(el.getAttribute("data-duration"));
if (durationAttr != null && durationAttr > 0) return durationAttr;
const endAttr = parseNumeric(el.getAttribute("data-end"));
if (endAttr != null) {
const start = resolveStart(doc, el, startCache, visiting);
const delta = endAttr - start;
if (Number.isFinite(delta) && delta > 0) return delta;
}
return null;
}
export function parseCompositions(html: string, baseDir: string): CompositionInfo[] {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const compositionDivs = doc.querySelectorAll("[data-composition-id]");
const compositions: CompositionInfo[] = [];
const startCache = new Map<Element, number>();
const visiting = new Set<Element>();
compositionDivs.forEach((div) => {
const id = div.getAttribute("data-composition-id") ?? "unknown";
@@ -75,7 +142,7 @@ function parseCompositions(html: string, baseDir: string): CompositionInfo[] {
timedChildren.forEach((el) => {
elementCount++;
const start = parseFloat(el.getAttribute("data-start") ?? "0");
const start = resolveStart(doc, el, startCache, visiting);
const endAttr = el.getAttribute("data-end");
const durationAttr = el.getAttribute("data-duration");
@@ -141,9 +208,11 @@ export function parseSubComposition(
// Also check timed children for max end time
if (compDiv) {
const timedEls = compDiv.querySelectorAll("[data-start]");
const startCache = new Map<Element, number>();
const visiting = new Set<Element>();
timedEls.forEach((el) => {
elementCount = Math.max(elementCount, timedEls.length);
const start = parseFloat(el.getAttribute("data-start") ?? "0");
const start = resolveStart(doc, el, startCache, visiting);
const endAttr = el.getAttribute("data-end");
const durAttr = el.getAttribute("data-duration");
@@ -58,28 +58,29 @@ window.__contrastAuditPrepare = function () {
}
function parseColor(c) {
var m = c.match(/rgba?\(([^)]+)\)/);
if (!m) return [0, 0, 0, 1];
var p = m[1].split(",").map(function (s) {
return parseFloat(s.trim());
});
return [p[0], p[1], p[2], p[3] != null ? p[3] : 1];
}
// Like parseColor, but returns null instead of defaulting to black when the
// value isn't a solid rgb()/rgba() color — e.g. SVG paint keywords such as
// "none"/"context-fill", or a gradient/pattern reference like
// 'url("#grad")'. Callers should fall back to another source of truth
// rather than trust a fabricated black.
function tryParseSolidColor(c) {
var m = c.match(/rgba?\(([^)]+)\)/);
if (!m) return null;
var m = (c || "").match(/^rgba?\(([^)]+)\)$/i);
if (!m) {
var mix = (c || "").match(
/^color-mix\(\s*in\s+srgb\s*,\s*(rgba?\([^)]+\))\s+(\d+(?:\.\d+)?|\.\d+)%\s*,\s*(rgba?\([^)]+\))\s+(\d+(?:\.\d+)?|\.\d+)%\s*\)$/i,
);
if (!mix) return null;
var first = parseColor(mix[1]);
var second = parseColor(mix[3]);
var firstWeight = parseFloat(mix[2]);
var secondWeight = parseFloat(mix[4]);
var weightTotal = firstWeight + secondWeight;
if (!first || !second || !isFinite(weightTotal) || weightTotal <= 0) return null;
return first.map(function (channel, index) {
return (channel * firstWeight + second[index] * secondWeight) / weightTotal;
});
}
var p = m[1].split(",").map(function (s) {
return parseFloat(s.trim());
});
if (
(p.length !== 3 && p.length !== 4) ||
p.some(function (v) {
return isNaN(v);
return !isFinite(v);
})
)
return null;
@@ -218,8 +219,12 @@ window.__contrastAuditPrepare = function () {
// `color` rather than crashing parseColor or reporting a fabricated
// black.
var isSvgText = isSvgTextElement(el);
var fg = isSvgText ? tryParseSolidColor(cs.fill) || parseColor(cs.color) : parseColor(cs.color);
var fg = isSvgText ? parseColor(cs.fill) || parseColor(cs.color) : parseColor(cs.color);
if (!fg) continue;
if (fg[3] <= 0.01) continue;
var strokeWidth = parseFloat(cs.webkitTextStrokeWidth || "0");
var stroke = strokeWidth > 0 ? parseColor(cs.webkitTextStrokeColor || "") : null;
if (stroke && stroke[3] <= 0.01) stroke = null;
var fontSize = parseFloat(cs.fontSize);
var fontWeight = Number(cs.fontWeight) || 400;
@@ -251,6 +256,13 @@ window.__contrastAuditPrepare = function () {
origFillPriority = el.style.getPropertyPriority("fill");
el.style.setProperty("fill", "transparent", "important");
}
var origStrokeColor = null,
origStrokeColorPriority = null;
if (stroke) {
origStrokeColor = el.style.getPropertyValue("-webkit-text-stroke-color");
origStrokeColorPriority = el.style.getPropertyPriority("-webkit-text-stroke-color");
el.style.setProperty("-webkit-text-stroke-color", "transparent", "important");
}
restores.push({
el: el,
origTransition: origTransition,
@@ -259,6 +271,9 @@ window.__contrastAuditPrepare = function () {
origColorPriority: origColorPriority,
origFill: origFill,
origFillPriority: origFillPriority,
origStrokeColor: origStrokeColor,
origStrokeColorPriority: origStrokeColorPriority,
hasStroke: !!stroke,
isSvgText: isSvgText,
});
@@ -266,6 +281,7 @@ window.__contrastAuditPrepare = function () {
selector: selectorOf(el),
text: (el.textContent || "").trim().slice(0, 50),
fg: fg,
stroke: stroke,
fontSize: fontSize,
fontWeight: fontWeight,
large: large,
@@ -287,6 +303,15 @@ function __contrastAuditRestoreAll() {
if (r.origFill) r.el.style.setProperty("fill", r.origFill, r.origFillPriority);
else r.el.style.removeProperty("fill");
}
if (r.hasStroke) {
if (r.origStrokeColor)
r.el.style.setProperty(
"-webkit-text-stroke-color",
r.origStrokeColor,
r.origStrokeColorPriority,
);
else r.el.style.removeProperty("-webkit-text-stroke-color");
}
if (r.origTransition)
r.el.style.setProperty("transition", r.origTransition, r.origTransitionPriority);
else r.el.style.removeProperty("transition");
@@ -371,17 +396,25 @@ window.__contrastAuditFinish = async function (imgBase64, time, candidates) {
var stepY = Math.max(1, Math.floor((y1 - y0) / 6));
var rr = [],
gg = [],
bb = [];
bb = [],
aa = [];
for (var y = y0; y <= y1; y += stepY) {
for (var x = x0; x <= x1; x += stepX) {
var idx = (y * w + x) * 4;
rr.push(px[idx]);
gg.push(px[idx + 1]);
bb.push(px[idx + 2]);
aa.push(px[idx + 3]);
}
}
if (rr.length === 0) continue;
// A transparent sampled backdrop is supplied by the downstream editor,
// not this composition. WCAG contrast cannot be inferred until that live
// video/image is known, so do not invent an opaque browser default and
// hard-fail an otherwise valid alpha overlay.
if (median(aa) < 255) continue;
var bgR = median(rr),
bgG = median(gg),
bgB = median(bb);
@@ -394,6 +427,23 @@ window.__contrastAuditFinish = async function (imgBase64, time, candidates) {
var ratio = +wcagRatio(compR, compG, compB, bgR, bgG, bgB).toFixed(2);
// A solid text stroke is part of the visible glyph paint. Use whichever
// of fill or stroke provides the stronger edge contrast, rather than
// failing readable outlined captions based on fill alone.
if (c.stroke) {
var stroke = c.stroke;
var strokeR = Math.round(stroke[0] * stroke[3] + bgR * (1 - stroke[3]));
var strokeG = Math.round(stroke[1] * stroke[3] + bgG * (1 - stroke[3]));
var strokeB = Math.round(stroke[2] * stroke[3] + bgB * (1 - stroke[3]));
var strokeRatio = +wcagRatio(strokeR, strokeG, strokeB, bgR, bgG, bgB).toFixed(2);
if (strokeRatio > ratio) {
compR = strokeR;
compG = strokeG;
compB = strokeB;
ratio = strokeRatio;
}
}
out.push({
time: time,
selector: c.selector,
@@ -23,4 +23,16 @@ describe("hyperframes-core contract docs", () => {
expect(brief).toContain("`hyperframes check`");
expect(brief).not.toContain("`lint` / `validate` / `inspect`");
});
it("requires actionable reproduction packets in CLI defect feedback", () => {
const skill = read("skills", "hyperframes-cli", "SKILL.md");
const renderReference = read("skills", "hyperframes-cli", "references", "preview-render.md");
expect(skill).toContain("reproduction packet");
expect(renderReference).toContain("REPRO COMMAND:");
expect(renderReference).toContain("EXPECTED / ACTUAL:");
expect(renderReference).toContain("EXACT ERROR:");
expect(renderReference).toContain("OUTCOME:");
expect(renderReference).toContain("WORKAROUND:");
});
});
+30
View File
@@ -88,6 +88,36 @@ describe("parseToolVersion", () => {
});
});
describe("checkArchiveExtractor", () => {
it("reports a missing unzip dependency on non-Windows hosts", async () => {
const doctor = await import("./doctor.js");
expect(doctor).toHaveProperty("checkArchiveExtractor");
const result = doctor.checkArchiveExtractor("linux", () => false);
expect(result).toEqual({
ok: false,
detail: "Not found",
hint: "Install unzip so HyperFrames can extract its managed Chrome download.",
});
});
it("accepts unzip on non-Windows hosts", async () => {
const { checkArchiveExtractor } = await import("./doctor.js");
expect(checkArchiveExtractor("darwin", (command) => command === "unzip")).toEqual({
ok: true,
detail: "unzip",
});
});
it("uses the built-in Windows extraction path", async () => {
const { checkArchiveExtractor } = await import("./doctor.js");
expect(checkArchiveExtractor("win32", () => false)).toEqual({
ok: true,
detail: "Built into Windows",
});
});
});
describe("buildDoctorReport", () => {
it("emits the locked schema shape", () => {
const report = buildDoctorReport(OUTCOMES_ALL_OK);
+33 -1
View File
@@ -1,6 +1,6 @@
// fallow-ignore-file complexity
import { defineCommand } from "citty";
import { execSync } from "node:child_process";
import { execFileSync, execSync } from "node:child_process";
import { platform } from "node:os";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
@@ -133,6 +133,37 @@ function checkDisk(): CheckResult {
return { ok: true, detail: `${freeGb} GB free` };
}
function commandExists(command: string): boolean {
try {
execFileSync("which", [command], { stdio: "ignore", timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Puppeteer's managed Chrome download delegates ZIP extraction to `unzip` on
* macOS and Linux. Windows has built-in tar/PowerShell extraction paths, so it
* does not need this Unix dependency.
*/
export function checkArchiveExtractor(
hostPlatform = platform(),
exists: (command: string) => boolean = commandExists,
): CheckResult {
if (hostPlatform === "win32") {
return { ok: true, detail: "Built into Windows" };
}
if (exists("unzip")) {
return { ok: true, detail: "unzip" };
}
return {
ok: false,
detail: "Not found",
hint: "Install unzip so HyperFrames can extract its managed Chrome download.",
};
}
function checkEnvironment(): CheckResult {
const sys = getSystemMeta();
const parts: string[] = [];
@@ -238,6 +269,7 @@ export default defineCommand({
{ name: "CPU", run: checkCPU },
{ name: "Memory", run: checkMemory },
{ name: "Disk", run: checkDisk },
{ name: "Archive extractor", run: checkArchiveExtractor },
];
// /dev/shm is only relevant on Linux (especially Docker)
+7 -11
View File
@@ -11,21 +11,17 @@ import { submitFeedback } from "../utils/submitFeedback.js";
import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "../utils/feedbackIssue.js";
import { VERSION } from "../version.js";
import { c } from "../ui/colors.js";
import { parseFeedbackRating } from "../utils/feedbackRating.js";
export const examples: Example[] = [
["Submit render feedback", 'hyperframes feedback --rating 4 --comment "fast but font missing"'],
["Quick rating only", "hyperframes feedback --rating 5"],
["Submit render feedback", 'hyperframes feedback --rating 8 --comment "fast but font missing"'],
["Quick rating only", "hyperframes feedback --rating 10"],
[
"Also file a GitHub issue with a published repro",
'hyperframes feedback --rating 2 --comment "GSAP timeline froze" --file-issue',
'hyperframes feedback --rating 3 --comment "GSAP timeline froze" --file-issue',
],
];
function parseRating(raw: string): number | null {
const n = parseInt(raw, 10);
return n >= 1 && n <= 5 && Number.isFinite(n) ? n : null;
}
function normalizeComment(raw?: string): string | undefined {
return raw || undefined;
}
@@ -122,7 +118,7 @@ export default defineCommand({
args: {
rating: {
type: "string",
description: "Satisfaction rating (1=poor, 5=great)",
description: "Likelihood to recommend (0=not likely, 10=extremely likely)",
required: true,
},
comment: {
@@ -146,9 +142,9 @@ export default defineCommand({
},
},
async run({ args }) {
const rating = parseRating(args.rating);
const rating = parseFeedbackRating(args.rating);
if (rating === null) {
console.error(c.error("Rating must be between 1 and 5"));
console.error(c.error("Rating must be an integer between 0 and 10"));
process.exit(1);
}
+19 -1
View File
@@ -9,7 +9,7 @@ import {
runAssetImportMany,
type AssetImportDeps,
} from "./asset.js";
import type { FigmaClient } from "@hyperframes/core/figma";
import { FigmaClientError, type FigmaClient } from "@hyperframes/core/figma";
const dirs: string[] = [];
function scratch(): string {
@@ -179,6 +179,24 @@ describe("runAssetImport", () => {
expect(new Set(results.map((r) => r.record.id)).size).toBe(3);
});
it("labels a batch-miss RENDER_FAILED with the images endpoint (telemetry parity with client.ts)", async () => {
const dir = scratch();
const missClient = fakeClient({
renderNodes: (fileKey, nodeIds) =>
Promise.resolve(nodeIds.map((nodeId) => ({ nodeId, url: null, ext: "png" }))),
});
const err = await runAssetImportMany(
["KEY:1-2"],
{ format: "png" },
deps(dir, { client: missClient }),
).catch((e: unknown) => e);
expect(err).toBeInstanceOf(FigmaClientError);
if (err instanceof FigmaClientError) {
expect(err.code).toBe("RENDER_FAILED");
expect(err.endpoint).toBe("images");
}
});
it("gatherAssetRefs splits bare comma-joined ids but keeps URLs whole", () => {
// bare tokens comma-split
expect(gatherAssetRefs(["KEY:1-2,KEY:3-4"])).toEqual(["KEY:1-2", "KEY:3-4"]);
+2
View File
@@ -209,6 +209,8 @@ export async function runAssetImportMany(
throw new FigmaClientError(
"RENDER_FAILED",
`figma could not render node ${nodeId} as ${opts.format}`,
undefined,
"images",
);
slots[i] = await freezeAndRecord(
fileKey,
@@ -29,6 +29,7 @@ export async function withFigmaErrors(command: string, fn: () => Promise<void>):
stack_trace: err.stack,
command,
kind: "command_error",
endpoint: err instanceof FigmaClientError ? err.endpoint : undefined,
});
await telemetry.flush();
} catch {
+51 -1
View File
@@ -4,7 +4,11 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { applyResolutionPreset, injectTailwindBrowserScript } from "./init.js";
import {
applyResolutionPreset,
injectTailwindBrowserScript,
resolveVideoDurationSeconds,
} from "./init.js";
const cliEntry = resolve(fileURLToPath(import.meta.url), "..", "..", "cli.ts");
const tailwindScript =
@@ -43,6 +47,32 @@ function expectScaffoldedScripts(target: string): void {
}
describe("hyperframes init flag rename", () => {
it("requires an explicit source in non-interactive mode", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
const target = join(dir, "proj");
try {
const res = runInit([target, "--non-interactive"]);
expect(res.status).toBe(1);
expect(res.stderr).toContain("Non-interactive init requires --example, --video, or --audio");
expect(existsSync(target)).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("rejects a following flag when --example has no value", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
const target = join(dir, "proj");
try {
const res = runInit([target, "--example", "--non-interactive"]);
expect(res.status).toBe(1);
expect(res.stderr).toContain("--example requires a value");
expect(existsSync(target)).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("--example blank scaffolds a bundled project with npm scripts", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
const target = join(dir, "proj");
@@ -151,6 +181,26 @@ describe("hyperframes init flag rename", () => {
}
});
it("uses the video stream duration when audio outlasts the final video frame", () => {
expect(
resolveVideoDurationSeconds({
streamDuration: 1,
frameDuration: 1,
formatDuration: 1.2,
}),
).toBe(1);
});
it("falls through unusable stream durations before using the container duration", () => {
expect(
resolveVideoDurationSeconds({
streamDuration: 0,
frameDuration: Number.NaN,
formatDuration: 1.2,
}),
).toBe(1.2);
});
it("--audio with a missing file fails without creating the project directory", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
const target = join(dir, "proj");
+47 -5
View File
@@ -14,10 +14,13 @@ export const examples: Example[] = [
["Start from an existing video file", "hyperframes init my-video --video clip.mp4"],
["Start from an audio file", "hyperframes init my-video --audio track.mp3"],
["Scaffold with Tailwind CSS", "hyperframes init my-video --example blank --tailwind"],
["Non-interactive mode (for CI or AI agents)", "hyperframes init my-video --non-interactive"],
[
"Non-interactive mode (for CI or AI agents)",
"hyperframes init my-video --example blank --non-interactive",
],
[
"Opt out of the GitHub skills check (CI/tests only)",
"HYPERFRAMES_SKIP_SKILLS=1 hyperframes init my-video --non-interactive",
"HYPERFRAMES_SKIP_SKILLS=1 hyperframes init my-video --example blank --non-interactive",
],
];
import {
@@ -78,6 +81,22 @@ const TAILWIND_BROWSER_SRC = `https://cdn.jsdelivr.net/npm/@tailwindcss/browser@
const TAILWIND_BROWSER_INTEGRITY =
"sha384-v5YF9xS+gLRWdvrQ0u/WRbCkjSIH0NjHIPe8tBL1ZRrmI7PiSH6LLdzs0aAIMCuh";
export function resolveVideoDurationSeconds({
streamDuration,
frameDuration,
formatDuration,
}: {
streamDuration: number;
frameDuration: number;
formatDuration: number;
}): number {
return (
[streamDuration, frameDuration, formatDuration].find(
(duration) => Number.isFinite(duration) && duration > 0,
) ?? DEFAULT_META.durationSeconds
);
}
// ---------------------------------------------------------------------------
// ffprobe helper — shells out to ffprobe to avoid engine dependency
// ---------------------------------------------------------------------------
@@ -100,6 +119,8 @@ function probeVideo(filePath: string): VideoMeta | undefined {
height?: number;
r_frame_rate?: string;
avg_frame_rate?: string;
duration?: string;
nb_frames?: string;
}[];
format?: { duration?: string };
} = JSON.parse(raw);
@@ -121,11 +142,18 @@ function probeVideo(filePath: string): VideoMeta | undefined {
}
}
const durationStr = parsed.format?.duration;
const durationSeconds = durationStr !== undefined ? parseFloat(durationStr) : 5;
const streamDuration = parseFloat(videoStream.duration ?? "");
const frameCount = parseInt(videoStream.nb_frames ?? "", 10);
const frameDuration = Number.isFinite(frameCount) && fps > 0 ? frameCount / fps : NaN;
const formatDuration = parseFloat(parsed.format?.duration ?? "");
const durationSeconds = resolveVideoDurationSeconds({
streamDuration,
frameDuration,
formatDuration,
});
return {
durationSeconds: Number.isNaN(durationSeconds) ? 5 : durationSeconds,
durationSeconds,
width: videoStream.width ?? 1920,
height: videoStream.height ?? 1080,
fps,
@@ -719,6 +747,10 @@ export default defineCommand({
process.exit(1);
}
const exampleFlag = args.example;
if (exampleFlag?.startsWith("-")) {
console.error(c.error(`--example requires a value; received flag "${exampleFlag}" instead.`));
process.exit(1);
}
const videoFlag = args.video;
const audioFlag = args.audio;
const skipTranscribe = args["skip-transcribe"] === true;
@@ -767,6 +799,16 @@ export default defineCommand({
// Non-interactive mode — all inputs from flags, defaults where missing
// -----------------------------------------------------------------------
if (!interactive) {
if (!exampleFlag && !videoFlag && !audioFlag) {
console.error(
c.error(
"Non-interactive init requires --example, --video, or --audio. " +
"For an empty starter project, pass --example blank explicitly.",
),
);
process.exit(1);
}
const templateId = exampleFlag ?? "blank";
const name = args.name ?? "my-video";
const destDir = resolve(name);
+123 -1
View File
@@ -1,9 +1,10 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { ensureDOMParser } from "../utils/dom.js";
import { collectShotSelectors, resolveScope, surfaceComposition } from "./keyframes.js";
import { ensureShotOutputDir } from "./motionShot.js";
beforeAll(() => ensureDOMParser());
@@ -26,6 +27,15 @@ describe("keyframes direct composition scope", () => {
});
});
describe("keyframes shot output", () => {
it("creates a missing parent directory before writing --shot", () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-keyframes-shot-dir-"));
const outputDir = join(projectDir, "nested", "proofs");
ensureShotOutputDir(join(outputDir, "shot.png"));
expect(existsSync(outputDir)).toBe(true);
});
});
describe("keyframes multi-stroke traces", () => {
it("composites ≥2 position strokes on one element into a single trace", () => {
const html = wrap(`
@@ -160,3 +170,115 @@ describe("keyframes runtime surfacing", () => {
expect(selectors).toEqual(expect.arrayContaining([".dot", ".chip"]));
});
});
describe("keyframes template-wrapped sub-compositions", () => {
// Sub-compositions are REQUIRED to put markup + script + style inside <template>.
// Template content is an inert DocumentFragment that document-level
// querySelectorAll does not traverse, so extraction must walk template.content
// too — otherwise every spec-conformant sub-composition surfaces zero motion.
const templateWrapped = `<template>
<style>
#hero { opacity: 0; }
@keyframes rise {
0% { transform: translateY(40px); }
100% { transform: translateY(0); }
}
</style>
<div id="root" data-composition-id="beat" data-duration="4">
<div id="hero" class="clip"></div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.fromTo("#hero", { y: 34, opacity: 0 }, { y: 0, opacity: 1, duration: 0.75 }, 0.5);
window.__timelines = { beat: tl };
</script>
</template>`;
it("surfaces GSAP tweens from a script inside <template>", () => {
const { tweens } = surfaceComposition(templateWrapped, "beat.html", "beat.html");
expect(tweens).toHaveLength(1);
expect(tweens[0]!.target).toBe("#hero");
});
it("surfaces @keyframes from a style inside <template>", () => {
const { cssKeyframes } = surfaceComposition(templateWrapped, "beat.html", "beat.html");
expect(cssKeyframes.map((k) => k.name)).toContain("rise");
});
it("aggregates extraction across multiple <template> blocks in one document", () => {
// Pins the every-template-fragment walk: styles from BOTH templates must
// surface, and a script in a NON-FIRST template must surface. (Two GSAP
// timelines in one file is a separate parser limitation — the static parser
// follows a single __timelines registration — so scripts are split so that
// the tween lives in the second template.)
const twoTemplates = `<template>
<style>
@keyframes rise { 0% { opacity: 0; } 100% { opacity: 1; } }
</style>
<div id="a" class="clip"></div>
</template>
<template>
<style>
@keyframes spin { 0% { transform: rotate(0); } 100% { transform: rotate(360deg); } }
</style>
<div id="b" class="clip"></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#b", { y: 50, duration: 1 });
window.__timelines = { multi: tl };
</script>
</template>`;
const { tweens, cssKeyframes } = surfaceComposition(twoTemplates, "multi.html", "multi.html");
expect(cssKeyframes.map((k) => k.name)).toEqual(expect.arrayContaining(["rise", "spin"]));
expect(tweens.map((t) => t.target)).toContain("#b");
});
it("reaches a <template> nested inside another template's fragment", () => {
const nested = `<template>
<div id="outer" class="clip"></div>
<template>
<style>
@keyframes inner-spin { 0% { transform: rotate(0); } 100% { transform: rotate(360deg); } }
</style>
<div id="inner" class="clip"></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#inner", { rotation: 360, duration: 1 });
window.__timelines = { nested: tl };
</script>
</template>
</template>`;
const { tweens, cssKeyframes } = surfaceComposition(nested, "nested.html", "nested.html");
expect(tweens.map((t) => t.target)).toContain("#inner");
expect(cssKeyframes.map((k) => k.name)).toContain("inner-spin");
});
it("parses mixed top-level + template scripts (join is not source order)", () => {
// A top-level script precedes the template script in the joined text even
// though extraction order differs from source order — the join must still
// parse and the template timeline must still surface.
const mixed = `<!doctype html><html><body>
<script>const themeUtil = { accent: "#7c3aed" };</script>
<template>
<div id="hero" class="clip"></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { x: 120, duration: 1 });
window.__timelines = { mixed: tl };
</script>
</template>
</body></html>`;
const { tweens } = surfaceComposition(mixed, "mixed.html", "mixed.html");
expect(tweens.map((t) => t.target)).toContain("#hero");
});
it("still surfaces top-level scripts outside any template", () => {
const topLevel = `<!doctype html><html><body><div id="dot" class="clip"></div><script>
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { x: 100, duration: 1 });
window.__timelines = [tl];
</script></body></html>`;
const { tweens } = surfaceComposition(topLevel, "index.html", "index.html");
expect(tweens.length).toBeGreaterThan(0);
});
});
+28 -4
View File
@@ -93,17 +93,37 @@ interface SurfacedComposition {
// ── GSAP extraction ──────────────────────────────────────────────────────────
function inlineScriptText(html: string): string {
// <template> content lives in an inert DocumentFragment that document-level
// querySelectorAll does not traverse — and sub-compositions are REQUIRED to wrap
// markup + script in <template>. Query the document and every template fragment
// (including templates nested inside another template's fragment, walked
// iteratively), or template-wrapped compositions surface zero tweens/keyframes.
// Ordering contract: document-level matches come first, then template contents
// in discovery order — NOT strict source order when a file mixes top-level and
// template scripts. Spec-conformant sub-compositions keep everything in one
// template, so mixed files only need to parse, not preserve interleaving.
function queryIncludingTemplates(html: string, selector: string): Element[] {
const doc = new DOMParser().parseFromString(html, "text/html");
return Array.from(doc.querySelectorAll("script"))
const roots: Array<{ querySelectorAll(s: string): Iterable<Element> }> = [doc];
const queue = Array.from(doc.querySelectorAll("template")) as HTMLTemplateElement[];
while (queue.length > 0) {
const content = queue.shift()!.content;
if (!content) continue;
roots.push(content);
queue.push(...(Array.from(content.querySelectorAll("template")) as HTMLTemplateElement[]));
}
return roots.flatMap((root) => Array.from(root.querySelectorAll(selector)));
}
function inlineScriptText(html: string): string {
return queryIncludingTemplates(html, "script")
.filter((s) => !s.getAttribute("src"))
.map((s) => s.textContent ?? "")
.join("\n");
}
function inlineStyleText(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");
return Array.from(doc.querySelectorAll("style"))
return queryIncludingTemplates(html, "style")
.map((s) => s.textContent ?? "")
.join("\n");
}
@@ -560,6 +580,10 @@ function collectCompositions(indexPath: string): SurfacedComposition[] {
surfaceComposition(html, basename(indexPath), basename(indexPath)),
];
// Deliberately document-root only (NOT queryIncludingTemplates): host divs
// with [data-composition-src] live in the orchestrating index.html light
// tree, never inside <template>. Widening this scan would change discovery
// semantics, not fix a gap.
const doc = new DOMParser().parseFromString(html, "text/html");
for (const div of Array.from(doc.querySelectorAll("[data-composition-src]"))) {
const src = div.getAttribute("data-composition-src");
+168 -35
View File
@@ -105,6 +105,10 @@
return !!element.closest("[data-layout-allow-overflow]");
}
function hasTextClipOptOut(element) {
return hasAllowOverflowFlag(element) || element.hasAttribute("data-layout-bleed");
}
function opacityChain(element) {
let opacity = 1;
for (let current = element; current; current = current.parentElement) {
@@ -215,10 +219,10 @@
const text = textContentFor(element, directOnly);
if (!text) return false;
if (directOnly) return true;
for (const child of Array.from(element.children)) {
if (isVisibleElement(child) && textContentFor(child)) return false;
}
return true;
// Aggregate text may come exclusively from descendants (including hidden
// captions). The container itself does not paint that text and must not be
// audited as though it did.
return textContentFor(element, true).length > 0;
}
function textClientRects(element, directOnly) {
@@ -394,6 +398,7 @@
}
function clippedTextIssue(element, time, tolerance) {
if (hasTextClipOptOut(element)) return null;
const style = getComputedStyle(element);
if (!clipsOverflow(style)) return null;
const overflowX = element.scrollWidth - element.clientWidth;
@@ -431,9 +436,9 @@
}
function textOverflowIssues(element, root, rootRect, time, tolerance) {
const textRect = textRectFor(element);
const textRect = textRectFor(element, true);
if (!textRect) return [];
const text = textContentFor(element);
const text = textContentFor(element, true);
const selector = selectorFor(element);
const issues = [];
@@ -455,7 +460,7 @@
const containerOverflow = overflowFor(textRect, containerRect, tolerance, verticalTolerance);
if (
containerOverflow &&
!hasAllowOverflowFlag(element) &&
!hasTextClipOptOut(element) &&
!clippedByAncestor(element, container)
) {
const style = elementStyle;
@@ -481,7 +486,7 @@
}
const canvasOverflow = overflowFor(textRect, rootRect, tolerance);
if (canvasOverflow && !hasAllowOverflowFlag(element)) {
if (canvasOverflow && !hasTextClipOptOut(element)) {
issues.push({
code: "canvas_overflow",
severity: "info",
@@ -566,7 +571,7 @@
// (low colour alpha) is decorative and exempt, as are elements opted out with
// data-layout-allow-overlap.
function isSolidTextBlock(element) {
if (!isVisibleElement(element) || !hasOwnTextCandidate(element)) return false;
if (!isVisibleElement(element) || !hasOwnTextCandidate(element, true)) return false;
if (hasAllowOverlapFlag(element)) return false;
return colorAlpha(getComputedStyle(element).color) >= 0.35;
}
@@ -575,8 +580,9 @@
const blocks = [];
for (const element of Array.from(root.querySelectorAll("*"))) {
if (!isSolidTextBlock(element)) continue;
const rect = textRectFor(element);
if (rect) blocks.push({ element, rect });
const rects = textClientRects(element, true);
const rect = textRectFor(element, true);
if (rect) blocks.push({ element, rect, rects });
}
return blocks;
}
@@ -591,6 +597,18 @@
return overlapX > 0 && overlapY > 0 ? overlapX * overlapY : 0;
}
function rectsArea(rects) {
return rects.reduce((total, rect) => total + rectArea(rect), 0);
}
function fragmentIntersectionArea(a, b) {
let total = 0;
for (const aRect of a) {
for (const bRect of b) total += intersectionArea(aRect, bRect);
}
return total;
}
function isNested(a, b) {
return a.contains(b) || b.contains(a);
}
@@ -626,8 +644,8 @@
function overlapIssue(a, b, time) {
if (isNested(a.element, b.element)) return null;
if (isManagedFlowOverlap(a.element, b.element)) return null;
const area = intersectionArea(a.rect, b.rect);
if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
const area = fragmentIntersectionArea(a.rects, b.rects);
if (area <= Math.min(rectsArea(a.rects), rectsArea(b.rects)) * 0.2) return null;
return {
// Warning at the per-sample level: a single-sample overlap is usually an
// entrance/exit transient (two blocks crossing mid-animation), not a real
@@ -730,13 +748,117 @@
const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]);
const imageAlphaCanvases = new WeakMap();
function objectPositionOffset(value, freeSpace) {
const token = String(value || "50%")
.trim()
.split(/\s+/)[0];
if (token === "left" || token === "top") return 0;
if (token === "right" || token === "bottom") return freeSpace;
if (token === "center") return freeSpace / 2;
if (token.endsWith("%")) return (freeSpace * parseFloat(token)) / 100;
const pixels = parseFloat(token);
return Number.isFinite(pixels) ? pixels : freeSpace / 2;
}
function objectPositionOffsets(value, freeX, freeY) {
const tokens = String(value || "50% 50%")
.trim()
.split(/\s+/)
.slice(0, 2);
let x = "50%";
let y = "50%";
if (tokens.length === 1) {
if (tokens[0] === "top" || tokens[0] === "bottom") y = tokens[0];
else x = tokens[0];
} else {
for (const token of tokens) {
if (token === "top" || token === "bottom") y = token;
else if (token === "left" || token === "right") x = token;
else if (x === "50%") x = token;
else y = token;
}
}
return { x: objectPositionOffset(x, freeX), y: objectPositionOffset(y, freeY) };
}
// Return the alpha painted by an <img> at a viewport point. `null` means the
// browser would not let us inspect the image (not loaded or cross-origin), in
// which case callers preserve the conservative opaque fallback.
function imageAlphaAt(element, x, y) {
const sourceWidth = element.naturalWidth;
const sourceHeight = element.naturalHeight;
const rect = element.getBoundingClientRect();
if (!sourceWidth || !sourceHeight || !rect.width || !rect.height) return null;
const style = getComputedStyle(element);
const fit = style.objectFit || "fill";
let scaleX = rect.width / sourceWidth;
let scaleY = rect.height / sourceHeight;
if (fit !== "fill") {
const contain = Math.min(scaleX, scaleY);
const cover = Math.max(scaleX, scaleY);
const scale =
fit === "cover"
? cover
: fit === "none"
? 1
: fit === "scale-down"
? Math.min(1, contain)
: contain;
scaleX = scale;
scaleY = scale;
}
const paintedWidth = sourceWidth * scaleX;
const paintedHeight = sourceHeight * scaleY;
const offsets = objectPositionOffsets(
style.objectPosition,
rect.width - paintedWidth,
rect.height - paintedHeight,
);
const localX = x - rect.left - offsets.x;
const localY = y - rect.top - offsets.y;
if (localX < 0 || localY < 0 || localX >= paintedWidth || localY >= paintedHeight) return 0;
try {
let cached = imageAlphaCanvases.get(element);
const source = element.currentSrc || element.src;
if (
!cached ||
cached.width !== sourceWidth ||
cached.height !== sourceHeight ||
cached.source !== source
) {
const canvas = document.createElement("canvas");
canvas.width = sourceWidth;
canvas.height = sourceHeight;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return null;
context.drawImage(element, 0, 0, sourceWidth, sourceHeight);
cached = { context, width: sourceWidth, height: sourceHeight, source };
imageAlphaCanvases.set(element, cached);
}
const sourceX = Math.min(sourceWidth - 1, Math.max(0, Math.floor(localX / scaleX)));
const sourceY = Math.min(sourceHeight - 1, Math.max(0, Math.floor(localY / scaleY)));
return cached.context.getImageData(sourceX, sourceY, 1, 1).data[3] / 255;
} catch {
return null;
}
}
// An element hides text beneath it when it paints opaque pixels at near-full
// opacity: raster content (img/video/canvas), a background image, or a solid
// background colour. Low-opacity overlays (grain, scrims) do not occlude.
function isOpaqueOccluder(element) {
if (opacityChain(element) < 0.6) return false;
function isOpaqueOccluder(element, x, y) {
const opacity = opacityChain(element);
if (opacity < 0.6) return false;
if (IGNORE_TAGS.has(element.tagName)) return false;
if (element.tagName === "IMG") {
const alpha = imageAlphaAt(element, x, y);
if (alpha !== null) return alpha * opacity >= 0.6;
}
if (RASTER_TAGS.has(element.tagName)) return true;
return hasOpaqueBackground(getComputedStyle(element));
}
@@ -798,7 +920,7 @@
// Pair-specific exemptions excuse this hit only; keep walking for deeper occluders.
if (sharedPreserve3d(element, hit)) continue;
if (isCrossSceneTransitionOverlap(element, hit)) continue;
if (isOpaqueOccluder(hit)) return hit;
if (isOpaqueOccluder(hit, x, y)) return hit;
}
return null;
}
@@ -819,25 +941,32 @@
return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text);
}
// Sweep a grid across the text box (three rows, not just the mid-line, so
// overlays covering only part of a multi-line block are caught). Unlike a
// Sweep a grid across each painted text fragment (three rows, not just the
// mid-line, so overlays covering only part of a multi-line block are caught).
// Sampling fragments instead of their union avoids probing empty line gaps.
// Unlike a
// first-hit scan, this keeps sampling every point so it can report what
// fraction of the box is actually covered — a corner nibble on a paragraph
// reads very differently from a label buried under an overlay. Still
// returns the first opaque element found, for `containerSelector`.
function occlusionCoverage(element, textRect) {
function occlusionCoverage(element, textRects) {
let occluder = null;
let hits = 0;
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
const y = textRect.top + textRect.height * yFraction;
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
if (!hit) continue;
hits += 1;
if (!occluder) occluder = hit;
for (const textRect of textRects) {
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
const y = textRect.top + textRect.height * yFraction;
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
if (!hit) continue;
hits += 1;
if (!occluder) occluder = hit;
}
}
}
return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) };
return {
occluder,
coveredFraction: round(hits / (OCCLUSION_GRID_POINTS * textRects.length)),
};
}
// pointer-events:none hides elements from elementFromPoint — both probed text AND occluders.
@@ -874,10 +1003,14 @@
function occludedTextIssue(element, time) {
if (hasAllowOcclusionFlag(element)) return null;
if (!hasVisibleTextInk(element)) return null;
const textRect = textRectFor(element);
const textRect = textRectFor(element, true);
if (!textRect) return null;
const text = textContentFor(element);
const { occluder, coveredFraction } = occlusionCoverage(element, textRect);
const textRects = textClientRects(element, true);
const text = textContentFor(element, true);
const { occluder, coveredFraction } = occlusionCoverage(
element,
textRects.length > 0 ? textRects : [textRect],
);
if (!occluder) return null;
if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null;
return {
@@ -907,9 +1040,9 @@
// paints the glyphs; a `background-clip: text` with no gradient/image and no
// opaque background-color paints nothing, so it stays reportable.
function invisibleTextIssue(element, time) {
const textRect = textRectFor(element);
const textRect = textRectFor(element, true);
if (!textRect) return null;
const text = textContentFor(element);
const text = textContentFor(element, true);
if (!text) return null;
const cs = getComputedStyle(element);
// Vendor computed-style props are read by property (camelCase), matching
@@ -1032,8 +1165,8 @@
if (escapedElements.has(element)) continue;
// Ownership is geometric and strict-mutex: any text breach past canvas_overflow's own
// tolerance cedes the element to canvas_overflow; in-bounds text leaves the panel finding.
if (hasOwnTextCandidate(element)) {
const textRect = textRectFor(element);
if (hasOwnTextCandidate(element, true)) {
const textRect = textRectFor(element, true);
if (textRect && overflowFor(textRect, rootRect, tolerance)) continue;
}
const rect = toRect(element.getBoundingClientRect());
@@ -1263,7 +1396,7 @@
document.body;
const rootRect = rootRectFor(root);
const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
isVisibleElement(element),
isVisibleElement(element, 0.05),
);
const issues = [];
@@ -63,6 +63,49 @@ describe("layout-audit.browser", () => {
expect(after).not.toBe(before);
});
// Opacity-reveal fixture (CLI feedback digest 2026-07-14): code-typing style
// scenes reveal pre-laid-out characters via opacity only — no geometry ever
// moves. The sweep fingerprint must treat that as motion, both while a glyph
// fades (opacity value changes) and when it crosses the 0.2 visibility floor
// (element enters the signature); otherwise `check` misfires `sweep_static`
// and authors reach for geometry hacks (a slow host y-drift) to pass.
it("changes the sweep fingerprint when text reveals via opacity alone", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="code"><span id="char">c</span></div>
</div>
`;
let charOpacity = "0";
installGeometry(
{
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
code: rect({ left: 40, top: 40, width: 560, height: 48 }),
char: rect({ left: 40, top: 40, width: 18, height: 48 }),
},
{
char: {
get opacity() {
return charOpacity;
},
} as Partial<CSSStyleDeclaration>,
},
);
installAuditScript();
const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string })
.__hyperframesLayoutGeometry;
const hidden = collect(); // below the 0.2 visibility floor — not in the signature
charOpacity = "0.5";
const fading = collect(); // mid-fade — present, opacity part of the signature
charOpacity = "1";
const revealed = collect(); // settled
expect(fading).not.toBe(hidden);
expect(revealed).not.toBe(fading);
});
it("uses authored canvas dimensions when the root bounding rect is degenerate", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
@@ -141,6 +184,49 @@ describe("layout-audit.browser", () => {
expect(runAudit()).toEqual([]);
});
it("suppresses intentional ellipsis clipping under overflow opt-outs", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="overflow-optout">
<div id="headline" style="width: 100px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">
Intentional long truncated label
</div>
</div>
</div>
`;
const headline = document.querySelector("#headline");
if (!(headline instanceof HTMLElement)) throw new Error("missing headline");
Object.defineProperties(headline, {
clientWidth: { configurable: true, value: 100 },
scrollWidth: { configurable: true, value: 240 },
clientHeight: { configurable: true, value: 20 },
scrollHeight: { configurable: true, value: 20 },
});
installGeometry(
{
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
headline: rect({ left: 40, top: 60, width: 100, height: 20 }),
text: rect({ left: 40, top: 60, width: 240, height: 20 }),
},
{
headline: { overflow: "hidden", overflowX: "hidden", overflowY: "hidden" },
},
);
installAuditScript();
const textOverflowCodes = () =>
runAudit()
.map((issue) => issue.code)
.filter((code) => code === "clipped_text" || code === "text_box_overflow");
expect(textOverflowCodes()).toEqual(["clipped_text", "text_box_overflow"]);
document.querySelector("#overflow-optout")?.setAttribute("data-layout-allow-overflow", "");
expect(textOverflowCodes()).toEqual([]);
document.querySelector("#overflow-optout")?.removeAttribute("data-layout-allow-overflow");
headline.setAttribute("data-layout-bleed", "true");
expect(textOverflowCodes()).toEqual([]);
});
it("does not flag glyph-ink vertical spill within the font-metric band on a non-clipping box", () => {
// A painted, non-clipping caption-word-like box whose glyph ink (text rect) exceeds its snug
// line-height box by a few px vertically — normal typography, nothing is clipped. (fontSize
@@ -199,6 +285,31 @@ describe("layout-audit.browser", () => {
]),
);
});
it("does not expand a parent's overflow geometry to a positioned descendant", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="headline">Visible copy<span id="positioned-copy">Positioned copy</span></div>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
headline: rect({ left: 40, top: 60, width: 200, height: 40 }),
"positioned-copy": rect({ left: 700, top: 60, width: 160, height: 40 }),
headlineText: rect({ left: 40, top: 60, width: 120, height: 40 }),
"positioned-copyText": rect({ left: 700, top: 60, width: 160, height: 40 }),
text: rect({ left: 40, top: 60, width: 820, height: 40 }),
},
{ "positioned-copy": { position: "absolute" } },
);
installAuditScript();
const parentOverflow = runAudit().find(
(issue) => issue.code === "canvas_overflow" && issue.selector === "#headline",
);
expect(parentOverflow).toBeUndefined();
});
});
it("is inert unless text or media candidates are explicitly requested", () => {
@@ -793,6 +904,20 @@ describe("layout-audit.browser content overlap", () => {
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
});
it("ignores another block placed only in a multiline text block's empty line gap", () => {
const issues = auditOverlapScene({
a: {
textRect: [
rect({ left: 100, top: 100, width: 400, height: 60 }),
rect({ left: 100, top: 260, width: 400, height: 60 }),
],
},
b: { textRect: rect({ left: 180, top: 180, width: 240, height: 50 }) },
});
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
});
it("ignores watermark-style text with low colour alpha", () => {
expectExemptFromOverlap({ color: "rgba(0, 0, 0, 0.2)" });
});
@@ -1089,6 +1214,119 @@ describe("contrast-audit.browser background sampling", () => {
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ selector: "#label", wcagAA: true, bg: "rgb(10,10,10)" });
});
it("resolves color-mix() foregrounds before computing contrast", async () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<span id="label">Mixed color</span>
</div>
`;
vi.spyOn(window, "getComputedStyle").mockImplementation(
() =>
({
display: "block",
visibility: "visible",
opacity: "1",
color: "color-mix(in srgb, rgb(37, 99, 235) 20%, rgb(255, 255, 255) 80%)",
fontSize: "20px",
fontWeight: "400",
clipPath: "none",
}) as unknown as CSSStyleDeclaration,
);
vi.spyOn(document.getElementById("label")!, "getBoundingClientRect").mockReturnValue(
rect({ left: 50, top: 50, width: 120, height: 30 }),
);
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
null;
installContrastScript(
pixelsWithRegion(
rect({ left: 0, top: 0, width: 640, height: 360 }),
[10, 10, 10],
[10, 10, 10],
),
);
const result = await runContrastAudit();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
selector: "#label",
fg: "rgb(211,224,251)",
bg: "rgb(10,10,10)",
ratio: 14.92,
wcagAA: true,
});
});
it("accepts outlined text when its stroke has adequate background contrast", async () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="caption">Outlined white caption</div>
</div>
`;
vi.spyOn(window, "getComputedStyle").mockImplementation(
() =>
({
display: "block",
visibility: "visible",
opacity: "1",
color: "rgb(255, 255, 255)",
webkitTextStrokeWidth: "8px",
webkitTextStrokeColor: "rgb(0, 0, 0)",
fontSize: "40px",
fontWeight: "700",
clipPath: "none",
}) as unknown as CSSStyleDeclaration,
);
vi.spyOn(document.getElementById("caption")!, "getBoundingClientRect").mockReturnValue(
rect({ left: 50, top: 50, width: 300, height: 60 }),
);
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
null;
installContrastScript();
const result = await runContrastAudit();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
selector: "#caption",
fg: "rgb(0,0,0)",
bg: "rgb(255,255,255)",
wcagAA: true,
});
});
it("skips text whose sampled backdrop remains transparent", async () => {
document.body.innerHTML = `
<div id="root" data-composition-id="overlay" data-width="640" data-height="360">
<span id="label">Live</span>
</div>
`;
vi.spyOn(window, "getComputedStyle").mockImplementation(
() =>
({
display: "block",
visibility: "visible",
opacity: "1",
color: "rgb(255, 255, 255)",
fontSize: "20px",
fontWeight: "700",
clipPath: "none",
}) as unknown as CSSStyleDeclaration,
);
vi.spyOn(document.getElementById("label")!, "getBoundingClientRect").mockReturnValue(
rect({ left: 50, top: 50, width: 100, height: 30 }),
);
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
null;
installContrastScript(new Uint8ClampedArray(640 * 360 * 4));
expect(await runContrastAudit()).toEqual([]);
});
});
// Both blocks overlap heavily; only the exemption on block A should suppress
@@ -1102,8 +1340,8 @@ function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string })
}
function auditOverlapScene(options: {
a: { textRect: DOMRect; color?: string; attrs?: string; clipPath?: string };
b: { textRect: DOMRect; color?: string; attrs?: string; clipPath?: string };
a: { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; clipPath?: string };
b: { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; clipPath?: string };
}): ReturnType<typeof runAudit> {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
@@ -1119,7 +1357,10 @@ function auditOverlapScene(options: {
a: options.a.clipPath ?? "none",
b: options.b.clipPath ?? "none",
};
const textRects: Record<string, DOMRect> = { a: options.a.textRect, b: options.b.textRect };
const textRects: Record<string, DOMRect[]> = {
a: normalizeTextRects(options.a.textRect),
b: normalizeTextRects(options.b.textRect),
};
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
const id = (element as Element).id;
@@ -1142,7 +1383,8 @@ function auditOverlapScene(options: {
for (const element of Array.from(document.querySelectorAll("*"))) {
vi.spyOn(element, "getBoundingClientRect").mockReturnValue(
textRects[element.id] ?? rect({ left: 0, top: 0, width: 1920, height: 1080 }),
boundingTextRect(textRects[element.id]) ??
rect({ left: 0, top: 0, width: 1920, height: 1080 }),
);
}
@@ -1153,10 +1395,12 @@ function auditOverlapScene(options: {
selected = node;
},
getClientRects() {
const id = (selected as Element | null)?.id ?? "";
return textRects[id]
? ([textRects[id]] as unknown as DOMRectList)
: ([] as unknown as DOMRectList);
const element =
selected?.nodeType === Node.TEXT_NODE
? selected.parentElement
: (selected as Element | null);
const id = element?.id ?? "";
return (textRects[id] ?? []) as unknown as DOMRectList;
},
detach() {},
} as unknown as Range;
@@ -1166,6 +1410,19 @@ function auditOverlapScene(options: {
return runAudit();
}
function normalizeTextRects(value: DOMRect | DOMRect[]): DOMRect[] {
return Array.isArray(value) ? value : [value];
}
function boundingTextRect(rects: DOMRect[] | undefined): DOMRect | undefined {
if (!rects?.length) return undefined;
const left = Math.min(...rects.map((item) => item.left));
const top = Math.min(...rects.map((item) => item.top));
const right = Math.max(...rects.map((item) => item.right));
const bottom = Math.max(...rects.map((item) => item.bottom));
return rect({ left, top, width: right - left, height: bottom - top });
}
function isFullyClipped(clipPath: string): boolean {
return /inset\([^)]*100%|circle\(0px/i.test(clipPath);
}
@@ -1206,6 +1463,24 @@ describe("layout-audit.browser occlusion", () => {
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});
it("does not treat transparent pixels in an image as text occlusion", () => {
const issues = auditImageOcclusionScene(0);
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});
it("still treats opaque pixels in an image as text occlusion", () => {
const occluded = auditImageOcclusionScene(255).find((issue) => issue.code === "text_occluded");
expect(occluded).toMatchObject({ selector: "#headline", containerSelector: "#overlay" });
});
it("does not treat object-fit letterboxing as image occlusion", () => {
const issues = auditImageOcclusionScene(255, {
objectFit: "contain",
headlineTextRect: rect({ left: 50, top: 500, width: 200, height: 80 }),
});
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});
it("respects the data-layout-allow-occlusion opt-out", () => {
const issues = auditOcclusionScene({
headlineAttrs: "data-layout-allow-occlusion",
@@ -1215,6 +1490,28 @@ describe("layout-audit.browser occlusion", () => {
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});
it("does not treat a visible container as painted text when its only text child is hidden", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="caption-container"><span id="caption">Hidden caption</span></div>
<div id="overlay"></div>
</div>
`;
installOcclusionGeometry({
styleOverrides: {
caption: { opacity: "0" },
overlay: { backgroundColor: "rgb(10, 10, 10)" },
},
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
topmostId: "overlay",
textRectElementId: "caption-container",
});
installAuditScript();
const issues = runAudit();
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});
it("carries the fully-covered fraction when the occluder hits every probe point", () => {
const occluded = auditOcclusionScene({
overlayStyle: { backgroundColor: "rgb(10, 10, 10)" },
@@ -1250,6 +1547,31 @@ describe("layout-audit.browser occlusion", () => {
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true);
});
it("does not sample opaque content in the gap between multiline text fragments", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="headline">First line of prose<br />Second line of prose</div>
<div id="overlay"></div>
</div>
`;
const lineRects = [
rect({ left: 200, top: 500, width: 600, height: 40 }),
rect({ left: 200, top: 580, width: 600, height: 40 }),
];
installOcclusionGeometry({
styleOverrides: { overlay: { backgroundColor: "rgb(10, 10, 10)" } },
headlineTextRect: lineRects,
topmostId: "headline",
});
(
document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }
).elementFromPoint = (_x, y) =>
document.getElementById(y > 540 && y < 580 ? "overlay" : "headline");
installAuditScript();
expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(false);
});
it("does not flag visible text carrying pointer-events:none (probe restores hit-testing)", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
@@ -1276,6 +1598,47 @@ describe("layout-audit.browser occlusion", () => {
expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(false);
});
it("audits only a container's direct text when a hidden descendant also has text", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="headline">Visible copy<span id="hidden-copy">Hidden copy</span></div>
<div id="overlay"></div>
</div>
`;
installOcclusionGeometry({
styleOverrides: {
"hidden-copy": { opacity: "0" },
overlay: { backgroundColor: "rgb(10, 10, 10)" },
},
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
topmostId: "overlay",
});
installAuditScript();
const issue = runAudit().find((candidate) => candidate.code === "text_occluded");
expect(issue?.text).toBe("Visible copy");
});
it("does not expand a container's text audit to a positioned descendant", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="headline">Visible copy<span id="positioned-copy">Positioned copy</span></div>
<div id="overlay"></div>
</div>
`;
installOcclusionGeometry({
styleOverrides: {
"positioned-copy": { position: "absolute" },
overlay: { backgroundColor: "rgb(10, 10, 10)" },
},
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
topmostId: "overlay",
});
installAuditScript();
const issues = runAudit().filter((candidate) => candidate.code === "text_occluded");
const headlineIssue = issues.find((candidate) => candidate.selector === "#headline");
expect(headlineIssue?.text).toBe("Visible copy");
});
it("does not count a low-alpha gradient overlay (grid/scrim) as an opaque occluder", () => {
const issues = auditOcclusionScene({
overlayStyle: {
@@ -1346,6 +1709,7 @@ describe("layout-audit.browser occlusion", () => {
},
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
topmostId: "overlay",
textRectElementId: "inner",
});
installAuditScript();
expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(true);
@@ -1476,10 +1840,44 @@ function auditOcclusionScene(options: {
return runAudit();
}
function auditImageOcclusionScene(
alpha: number,
options: { objectFit?: string; headlineTextRect?: DOMRect } = {},
): ReturnType<typeof runAudit> {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="headline">Headline copy</div>
<img id="overlay" src="paper.png" alt="" />
</div>
`;
const overlay = document.getElementById("overlay") as HTMLImageElement;
Object.defineProperties(overlay, {
naturalWidth: { configurable: true, value: 100 },
naturalHeight: { configurable: true, value: 100 },
});
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as {
mockReturnValue(value: CanvasRenderingContext2D): void;
};
getContextSpy.mockReturnValue({
drawImage: vi.fn(),
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([0, 0, 0, alpha]) })),
} as unknown as CanvasRenderingContext2D);
installOcclusionGeometry({
styleOverrides: { overlay: { objectFit: options.objectFit ?? "fill" } },
headlineTextRect:
options.headlineTextRect ?? rect({ left: 200, top: 500, width: 600, height: 80 }),
topmostId: "overlay",
});
overlay.getBoundingClientRect = () => rect({ left: 0, top: 0, width: 1920, height: 1080 });
installAuditScript();
return runAudit();
}
function installOcclusionGeometry(options: {
styleOverrides: Record<string, Partial<Record<string, string>>>;
headlineTextRect: DOMRect;
headlineTextRect: DOMRect | DOMRect[];
topmostId: string;
textRectElementId?: string;
}): void {
const baseStyle: Record<string, string> = {
display: "block",
@@ -1526,8 +1924,12 @@ function installOcclusionGeometry(options: {
selected = node;
},
getClientRects() {
return (selected as Element | null)?.id === "headline"
? ([options.headlineTextRect] as unknown as DOMRectList)
const selectedElement =
selected?.nodeType === Node.TEXT_NODE
? (selected.parentElement as Element | null)
: (selected as Element | null);
return selectedElement?.id === (options.textRectElementId ?? "headline")
? (normalizeTextRects(options.headlineTextRect) as unknown as DOMRectList)
: ([] as unknown as DOMRectList);
},
detach() {},
@@ -1657,6 +2059,7 @@ async function runContrastAudit(): Promise<Array<Record<string, unknown>>> {
interface AuditIssue {
code: string;
selector: string;
text?: string;
containerSelector?: string;
overflow?: Record<string, number>;
message?: string;
@@ -1673,6 +2076,20 @@ function runAudit(): AuditIssue[] {
return audit({ time: 1, tolerance: 2 });
}
function selectedRangeElement(selected: Node | null): Element | null {
return selected?.nodeType === Node.TEXT_NODE
? (selected.parentElement as Element | null)
: (selected as Element | null);
}
function rangeTextRect(selected: Node | null, rects: Record<string, DOMRect>): DOMRect | undefined {
const element = selectedRangeElement(selected);
if (element?.id === "ignored") return rects.ignored;
if (selected?.nodeType === Node.TEXT_NODE && element?.id)
return rects[`${element.id}Text`] ?? rects.text;
return rects.text;
}
function installGeometry(
rects: Record<string, DOMRect>,
styleOverrides: Record<string, Partial<CSSStyleDeclaration>> = {},
@@ -1727,8 +2144,7 @@ function installGeometry(
selected = node;
},
getClientRects() {
const element = selected as Element | null;
const textRect = element?.id === "ignored" ? rects.ignored : rects.text;
const textRect = rangeTextRect(selected, rects);
return textRect ? ([textRect] as unknown as DOMRectList) : ([] as unknown as DOMRectList);
},
detach() {},
+9 -2
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { resolveProject } from "../utils/project.js";
import { resolveDiagnosticNavigationTimeoutMs } from "../utils/renderArgs.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js";
@@ -180,7 +181,10 @@ async function alignViewportToComposition(
});
await page.setViewport(size);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.goto(url, {
waitUntil: "domcontentloaded",
timeout: resolveDiagnosticNavigationTimeoutMs(),
});
}
async function runLayoutAudit(
@@ -217,7 +221,10 @@ async function runLayoutAudit(
const page = await chromeBrowser.newPage();
await installPageFunctionGuard(page);
await page.setViewport({ width: 1920, height: 1080 });
await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.goto(server.url, {
waitUntil: "domcontentloaded",
timeout: resolveDiagnosticNavigationTimeoutMs(),
});
await alignViewportToComposition(page, server.url);
await page
.waitForFunction(() => !!(window as unknown as { __timelines?: unknown }).__timelines, {
+11 -3
View File
@@ -10,7 +10,9 @@
// exactly what it's editing. All geometry + SVG live in ./motionShotLayout.ts
// (pure, tested); this file only drives the browser and SAMPLES.
import { writeFileSync } from "node:fs";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { resolveDiagnosticNavigationTimeoutMs } from "../utils/renderArgs.js";
import {
buildOnionSvg,
ghostAlphas,
@@ -25,6 +27,10 @@ export interface ShotRequest {
selector: string;
}
export function ensureShotOutputDir(outPath: string): void {
mkdirSync(dirname(outPath), { recursive: true });
}
/** Returned by the in-browser selector resolver: which animated selectors a
* `--selector SCOPE` actually resolves to (scope itself, or its descendants),
* plus diagnostic context when nothing under the scope animates. */
@@ -258,7 +264,8 @@ async function openCompositionPage(
],
});
const page = await browser.newPage();
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
const navigationTimeout = resolveDiagnosticNavigationTimeoutMs();
await page.goto(url, { waitUntil: "domcontentloaded", timeout: navigationTimeout });
const size = await page.evaluate(() => {
const root = document.querySelector("[data-composition-id][data-width][data-height]");
const w = root ? parseInt(root.getAttribute("data-width") ?? "", 10) : 0;
@@ -269,7 +276,7 @@ async function openCompositionPage(
};
});
await page.setViewport(size);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.goto(url, { waitUntil: "domcontentloaded", timeout: navigationTimeout });
await page
.waitForFunction(() => !!(window as unknown as { __timelines?: unknown }).__timelines, {
timeout: 10000,
@@ -611,6 +618,7 @@ export async function captureMotionPathShot(
outPath: string,
opts: ShotOptions = {},
): Promise<string> {
ensureShotOutputDir(outPath);
let requests = requestsIn;
const samples = Math.max(1, Math.min(60, opts.samples ?? 9));
const layout = opts.layout ?? "path";
+53
View File
@@ -0,0 +1,53 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { studioLandingSearch } from "./preview.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function projectWith(storyboard: string | null, frameFiles: string[] = []): string {
const dir = mkdtempSync(join(tmpdir(), "hf-preview-landing-"));
tempDirs.push(dir);
if (storyboard !== null) writeFileSync(join(dir, "STORYBOARD.md"), storyboard);
for (const file of frameFiles) {
mkdirSync(join(dir, file, ".."), { recursive: true });
writeFileSync(join(dir, file), "<div></div>");
}
return dir;
}
const FRAME = (n: number, status: string) =>
`## Frame ${n} — F${n}\n- status: ${status}\n- src: compositions/frames/0${n}.html\n\nBeat.\n`;
describe("studioLandingSearch", () => {
it("returns no search without a storyboard", () => {
expect(studioLandingSearch(projectWith(null))).toBe("");
});
it("lands on the board while sketches are under review (any built frame)", () => {
const dir = projectWith(`${FRAME(1, "built")}${FRAME(2, "outline")}`, [
"compositions/frames/01.html",
]);
expect(studioLandingSearch(dir)).toBe("?view=storyboard");
});
it("lands on the board during pure planning (srcs declared, none exist)", () => {
const dir = projectWith(`${FRAME(1, "outline")}${FRAME(2, "outline")}`);
expect(studioLandingSearch(dir)).toBe("?view=storyboard");
});
it("lands on the timeline once frames exist without a built status", () => {
const dir = projectWith(`${FRAME(1, "outline")}`, ["compositions/frames/01.html"]);
expect(studioLandingSearch(dir)).toBe("");
});
it("lands on the timeline for fully animated boards", () => {
const dir = projectWith(`${FRAME(1, "animated")}`, ["compositions/frames/01.html"]);
expect(studioLandingSearch(dir)).toBe("");
});
});
+145 -8
View File
@@ -10,6 +10,9 @@ export const examples: Example[] = [
["Preview a specific project directory", "hyperframes preview ./my-video"],
["Use a custom port", "hyperframes preview --port 8080"],
["Force a new server even if one is already running", "hyperframes preview --force-new"],
["Keep preview running after this command exits", "hyperframes preview --background"],
["Show the background preview for this project", "hyperframes preview --status"],
["Stop the background preview for this project", "hyperframes preview --stop"],
["Start without opening the browser", "hyperframes preview --no-open"],
["Open with a specific browser", "hyperframes preview --browser-path /usr/bin/chromium"],
[
@@ -19,7 +22,16 @@ export const examples: Example[] = [
["List all active preview servers", "hyperframes preview --list"],
["Kill all active preview servers", "hyperframes preview --kill-all"],
];
import { existsSync, lstatSync, symlinkSync, unlinkSync, readlinkSync, mkdirSync } from "node:fs";
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readlinkSync,
symlinkSync,
unlinkSync,
} from "node:fs";
import { parseStoryboard, STORYBOARD_FILENAME } from "@hyperframes/core/storyboard";
import { resolve, dirname, basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
@@ -44,6 +56,11 @@ import {
} from "../server/portUtils.js";
import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js";
import { resolveProject } from "../utils/project.js";
import {
readBackgroundPreviewStatus,
startBackgroundPreview,
stopBackgroundPreview,
} from "./previewLifecycle.js";
interface BrowserLaunchOptions {
noOpen?: boolean;
@@ -90,6 +107,21 @@ export default defineCommand({
description: "Start a new server even if one is already running for this project",
default: false,
},
background: {
type: "boolean",
description: "Start an embedded preview that remains running after the command exits",
default: false,
},
status: {
type: "boolean",
description: "Show the background preview for this project and exit",
default: false,
},
stop: {
type: "boolean",
description: "Stop the background preview for this project and exit",
default: false,
},
list: {
type: "boolean",
description: "List all active preview servers and exit",
@@ -154,6 +186,30 @@ export default defineCommand({
const startPort = parseInt(args.port ?? "3002", 10);
const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined;
if (args.status || args.stop) {
const project = resolveProject(args.dir);
if (args.stop) {
const stopped = await stopBackgroundPreview(project.dir, startPort);
console.log(
stopped
? `\n ${c.success("Stopped background preview")} ${c.dim(project.dir)}\n`
: `\n ${c.dim("No background preview is running for")} ${project.dir}\n`,
);
return;
}
const status = await readBackgroundPreviewStatus(project.dir, startPort);
if (!status) {
console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`);
return;
}
console.log(`\n ${c.success("Background preview running")}`);
console.log(
` ${c.accent(`http://localhost:${status.port}`)} ${c.dim(`(PID ${status.pid})`)}`,
);
console.log(` ${c.dim(status.logPath)}\n`);
return;
}
// --list: scan and display active servers
if (args.list) {
const servers = await scanActiveServers(startPort);
@@ -267,6 +323,11 @@ export default defineCommand({
}
if (isDevMode()) {
if (args.background) {
clack.log.error("--background currently supports the embedded preview server only");
process.exitCode = 1;
return;
}
return runDevMode(dir, {
projectName,
noOpen,
@@ -279,6 +340,11 @@ export default defineCommand({
// If @hyperframes/studio is installed locally, use Vite for full HMR
if (hasLocalStudio(dir)) {
if (args.background) {
clack.log.error("--background currently supports the embedded preview server only");
process.exitCode = 1;
return;
}
return runLocalStudioMode(dir, {
projectName,
noOpen,
@@ -289,6 +355,38 @@ export default defineCommand({
});
}
if (args.background) {
let background;
try {
background = await startBackgroundPreview(dir, startPort, {
forceNew: Boolean(args["force-new"]),
});
} catch (error) {
clack.log.error(errorMessage(error));
process.exitCode = 1;
return;
}
const url = `http://localhost:${background.port}`;
clack.intro(c.bold("hyperframes preview"));
printStudioSummary(projectName, url, {
details: [
background.type === "reused"
? "Reusing the background server already running for this project."
: `Running in the background. Log: ${background.logPath}`,
"Changes reload automatically in the studio.",
],
footer: `Stop with: hyperframes preview ${JSON.stringify(dir)} --stop`,
});
openStudioBrowser(url, projectName, dir, {
noOpen,
browserPath,
userDataDir,
remoteDebuggingPort,
browserNoGpu,
});
return;
}
const forceNew = !!args["force-new"];
return runEmbeddedMode(dir, startPort, {
projectName,
@@ -653,9 +751,47 @@ function compactSelectionPayload(selection: StudioSelectionSnapshot): CompactSel
};
}
function openStudioBrowser(url: string, projectName: string, options?: BrowserLaunchOptions): void {
// Land the browser on the Storyboard view while the project is still planning
// or sketching — the timeline only becomes the right landing once frames are
// animated (or the storyboard never tracked statuses at all, e.g. beat plans).
export function studioLandingSearch(projectDir: string): string {
const storyboardPath = join(projectDir, STORYBOARD_FILENAME);
if (!existsSync(storyboardPath)) return "";
let frames;
try {
frames = parseStoryboard(readFileSync(storyboardPath, "utf8")).frames;
} catch {
return "";
}
// Sketch review in progress — the board is the review surface.
if (frames.some((f) => f.status === "built")) return "?view=storyboard";
// Pure planning stage: frames declare src paths but none are built yet.
const srcs = frames
.map((f) => f.src)
.filter((s): s is string => typeof s === "string" && s.length > 0);
const planning =
frames.length > 0 &&
frames.every((f) => f.status === "outline") &&
srcs.length > 0 &&
!srcs.some((s) => existsSync(join(projectDir, s)));
return planning ? "?view=storyboard" : "";
}
// The full Studio URL to open or hand to the user: status-aware landing view
// plus the project hash route. `url` never carries a trailing slash (both the
// embedded server and the Vite `Local:` match strip it).
function studioDeepLink(url: string, projectName: string, projectDir: string): string {
return `${url}/${studioLandingSearch(projectDir)}#project/${projectName}`;
}
function openStudioBrowser(
url: string,
projectName: string,
projectDir: string,
options?: BrowserLaunchOptions,
): void {
if (options?.noOpen) return;
openBrowser(`${url}#project/${projectName}`, {
openBrowser(studioDeepLink(url, projectName, projectDir), {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
@@ -740,6 +876,7 @@ function attachStudioReadyHandler(
child: StudioChildProcess,
spinner: ReturnType<typeof clack.spinner>,
projectName: string,
projectDir: string,
options?: BrowserLaunchOptions,
): void {
let detected = false;
@@ -751,7 +888,7 @@ function attachStudioReadyHandler(
detected = true;
spinner.stop(c.success("Studio running"));
printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" });
openStudioBrowser(url, projectName, options);
openStudioBrowser(url, projectName, projectDir, options);
child.stdout.removeListener("data", handleOutput);
child.stderr.removeListener("data", handleOutput);
}
@@ -789,7 +926,7 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise<v
stdio: ["ignore", "pipe", "pipe"],
});
attachStudioReadyHandler(child, s, pName, options);
attachStudioReadyHandler(child, s, pName, dir, options);
removeSymlinkOnExit(createdSymlink, symlinkPath);
// Kill the child's entire process tree on SIGTERM/SIGINT. Ctrl+C sends
@@ -838,7 +975,7 @@ async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): P
stdio: ["ignore", "pipe", "pipe"],
});
attachStudioReadyHandler(child, s, pName, options);
attachStudioReadyHandler(child, s, pName, dir, options);
removeSymlinkOnExit(createdSymlink, symlinkPath);
// Same tree-kill handler as dev mode. No-op on Windows (see comment above).
@@ -909,7 +1046,7 @@ async function runEmbeddedMode(
printStudioSummary(pName, url, {
details: ["Reusing existing server. Use --force-new to start a fresh instance."],
});
openStudioBrowser(url, pName, options);
openStudioBrowser(url, pName, dir, options);
return;
}
@@ -927,7 +1064,7 @@ async function runEmbeddedMode(
],
footer: "Press Ctrl+C to stop",
});
openStudioBrowser(url, pName, options);
openStudioBrowser(url, pName, dir, options);
// Block until Ctrl+C. Node would normally exit on SIGINT, but the listening
// HTTP server keeps handles open, so the event loop stays alive after the
@@ -0,0 +1,221 @@
import { existsSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { ActiveServer } from "../server/portUtils.js";
import {
buildBackgroundPreviewArgs,
previewSessionPath,
readBackgroundPreviewStatus,
startBackgroundPreview,
stopBackgroundPreview,
writePreviewSession,
} from "./previewLifecycle.js";
const projectDir = resolve("/tmp/hyperframes-preview-lifecycle-project");
const server: ActiveServer = {
port: 3210,
projectName: "preview-lifecycle-project",
projectDir,
version: "test",
pid: "4321",
};
function savePreviewSession(stateHome: string): void {
writePreviewSession(
{ pid: 4321, port: 3210, projectDir, logPath: "/tmp/preview.log" },
stateHome,
);
}
async function expectStaleSessionRemoved(stateHome: string): Promise<void> {
const status = await readBackgroundPreviewStatus(projectDir, 3002, {
scan: async () => [],
stateHome,
});
expect(status).toBeNull();
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false);
}
describe("background preview lifecycle", () => {
it("keeps case-distinct project paths separate on case-sensitive platforms", () => {
if (process.platform === "win32") return;
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
expect(previewSessionPath("/tmp/Project", stateHome)).not.toBe(
previewSessionPath("/tmp/project", stateHome),
);
});
it("builds a detached child invocation without recursively preserving --background", () => {
expect(
buildBackgroundPreviewArgs([
"/opt/hyperframes/cli.js",
"preview",
projectDir,
"--background",
"--open",
]),
).toEqual(["/opt/hyperframes/cli.js", "preview", projectDir, "--no-open"]);
});
it("reuses an already-running server for the same project", async () => {
const spawn = vi.fn();
const scan = vi.fn(async () => [server]);
const result = await startBackgroundPreview(projectDir, 3002, {
argv: ["/opt/hyperframes/cli.js", "preview", projectDir, "--background"],
execPath: "/usr/bin/node",
scan,
spawn,
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
});
expect(result).toMatchObject({ type: "reused", port: 3210 });
expect(spawn).not.toHaveBeenCalled();
});
it("force-new waits for a different server instead of reusing the existing one", async () => {
const replacement = { ...server, port: 3211, pid: "5432" };
let scans = 0;
const scan = vi.fn(async () => (++scans < 3 ? [server] : [server, replacement]));
const spawn = vi.fn(() => ({ pid: 5432, unref: vi.fn() }));
const result = await startBackgroundPreview(projectDir, 3002, {
forceNew: true,
scan,
spawn,
sleep: async () => {},
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
});
expect(result).toMatchObject({ type: "started", port: 3211, pid: 5432 });
expect(spawn).toHaveBeenCalledOnce();
});
it("returns after a detached child becomes reachable and records its session", async () => {
let scans = 0;
const scan = vi.fn(async () => (++scans < 2 ? [] : [server]));
const unref = vi.fn();
const spawn = vi.fn(() => ({ pid: 4321, unref }));
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const result = await startBackgroundPreview(projectDir, 3002, {
argv: ["/opt/hyperframes/cli.js", "preview", projectDir, "--background"],
execPath: "/usr/bin/node",
scan,
spawn,
sleep: async () => {},
stateHome,
});
expect(result).toMatchObject({ type: "started", port: 3210, pid: 4321 });
expect(unref).toHaveBeenCalledOnce();
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true);
});
it("removes a stale session when no matching server or process survives", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
writePreviewSession(
{ pid: 999_999, port: 3210, projectDir, logPath: "/tmp/missing.log" },
stateHome,
);
await expectStaleSessionRemoved(stateHome);
});
it("removes stale session metadata when its PID is alive but no server proves ownership", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
savePreviewSession(stateHome);
await expectStaleSessionRemoved(stateHome);
});
it("uses the recorded custom port when status is called without repeating --port", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
savePreviewSession(stateHome);
const scan = vi.fn(async () => [server]);
const status = await readBackgroundPreviewStatus(projectDir, 3002, { scan, stateHome });
expect(status?.port).toBe(3210);
expect(scan).toHaveBeenCalledWith(3210);
});
it("stops only the matching project server and waits until it is unreachable", async () => {
let running = true;
const scan = vi.fn(async () => (running ? [server] : []));
const kill = vi.fn(() => {
running = false;
});
const result = await stopBackgroundPreview(projectDir, 3002, {
scan,
kill,
sleep: async () => {},
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
});
expect(result).toBe(true);
expect(kill).toHaveBeenCalledWith(4321);
expect(scan).toHaveBeenCalledTimes(2);
});
it("does not kill an unmatched saved PID that may have been reused", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
savePreviewSession(stateHome);
const kill = vi.fn();
const result = await stopBackgroundPreview(projectDir, 3002, {
scan: async () => [],
kill,
stateHome,
});
expect(result).toBe(false);
expect(kill).not.toHaveBeenCalled();
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false);
});
it("uses the saved child PID when a matching live server cannot report one", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
writePreviewSession(
{ pid: 4321, port: 3210, projectDir, logPath: "/tmp/preview.log" },
stateHome,
);
let running = true;
const scan = vi.fn(async () => (running ? [{ ...server, pid: null }] : []));
const kill = vi.fn(() => {
running = false;
});
const result = await stopBackgroundPreview(projectDir, 3002, {
scan,
kill,
sleep: async () => {},
stateHome,
});
expect(result).toBe(true);
expect(kill).toHaveBeenCalledWith(4321);
});
it("fails loudly when the server remains reachable after stop", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
writePreviewSession(
{ pid: 4321, port: 3210, projectDir, logPath: "/tmp/preview.log" },
stateHome,
);
await expect(
stopBackgroundPreview(projectDir, 3002, {
scan: async () => [server],
kill: vi.fn(),
sleep: async () => {},
stateHome,
}),
).rejects.toThrow(/did not stop/i);
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true);
});
});
@@ -0,0 +1,267 @@
import { spawn as nodeSpawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { scanActiveServers, type ActiveServer } from "../server/portUtils.js";
import { killProcessTree } from "../utils/orphanCleanup.js";
export interface PreviewSession {
pid: number;
port: number;
projectDir: string;
logPath: string;
}
type SpawnResult = { pid?: number; unref(): void };
type SpawnPreview = (
command: string,
args: string[],
options: {
detached: boolean;
stdio: ["ignore", number, number];
env: NodeJS.ProcessEnv;
},
) => SpawnResult;
interface LifecycleDependencies {
argv?: string[];
execPath?: string;
scan?: (startPort?: number) => Promise<ActiveServer[]>;
spawn?: SpawnPreview;
sleep?: (ms: number) => Promise<void>;
kill?: (pid: number) => void;
stateHome?: string;
forceNew?: boolean;
}
function defaultStateHome(): string {
return process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
}
function normalized(path: string): string {
const resolved = resolve(path).replace(/\\/g, "/");
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
function sessionDirectory(stateHome = defaultStateHome()): string {
return join(stateHome, "hyperframes", "previews");
}
export function previewSessionPath(projectDir: string, stateHome = defaultStateHome()): string {
const key = createHash("sha256").update(normalized(projectDir)).digest("hex").slice(0, 16);
return join(sessionDirectory(stateHome), `${key}.json`);
}
function previewLogPath(projectDir: string, stateHome = defaultStateHome()): string {
return previewSessionPath(projectDir, stateHome).replace(/\.json$/, ".log");
}
export function writePreviewSession(session: PreviewSession, stateHome = defaultStateHome()): void {
const path = previewSessionPath(session.projectDir, stateHome);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 });
}
function readPreviewSession(
projectDir: string,
stateHome = defaultStateHome(),
): PreviewSession | null {
const path = previewSessionPath(projectDir, stateHome);
if (!existsSync(path)) return null;
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as PreviewSession;
if (
!Number.isInteger(parsed.pid) ||
parsed.pid <= 0 ||
normalized(parsed.projectDir) !== normalized(projectDir)
) {
throw new Error("invalid preview session");
}
return parsed;
} catch {
rmSync(path, { force: true });
return null;
}
}
function removePreviewSession(projectDir: string, stateHome = defaultStateHome()): void {
rmSync(previewSessionPath(projectDir, stateHome), { force: true });
}
function matchingServer(servers: ActiveServer[], projectDir: string): ActiveServer | null {
return servers.find((server) => normalized(server.projectDir) === normalized(projectDir)) ?? null;
}
function stopProcess(pid: number): void {
killProcessTree(pid);
if (process.platform === "win32") {
try {
process.kill(pid, "SIGTERM");
} catch {
// Process already exited.
}
}
}
const delay = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
function spawnDetachedPreview(
projectDir: string,
stateHome: string,
dependencies: LifecycleDependencies,
): { pid: number; logPath: string } {
const logPath = previewLogPath(projectDir, stateHome);
mkdirSync(dirname(logPath), { recursive: true });
const logFd = openSync(logPath, "a", 0o600);
const spawn = dependencies.spawn ?? (nodeSpawn as unknown as SpawnPreview);
let child: SpawnResult;
try {
child = spawn(
dependencies.execPath ?? process.execPath,
buildBackgroundPreviewArgs(dependencies.argv ?? process.argv.slice(1)),
{
detached: true,
stdio: ["ignore", logFd, logFd],
env: process.env,
},
);
} finally {
closeSync(logFd);
}
if (!child.pid) throw new Error("background preview child did not report a PID");
child.unref();
return { pid: child.pid, logPath };
}
function startedServer(
servers: ActiveServer[],
projectDir: string,
existing: ActiveServer | null,
forceNew: boolean,
): ActiveServer | null {
const candidates =
forceNew && existing ? servers.filter((server) => server.port !== existing.port) : servers;
return matchingServer(candidates, projectDir);
}
export function buildBackgroundPreviewArgs(argv: string[]): string[] {
const filtered = argv.filter(
(arg) =>
arg !== "--background" &&
!arg.startsWith("--background=") &&
arg !== "--open" &&
arg !== "--no-open",
);
return [...filtered, "--no-open"];
}
export async function readBackgroundPreviewStatus(
projectDir: string,
startPort: number,
dependencies: LifecycleDependencies = {},
): Promise<PreviewSession | null> {
const scan = dependencies.scan ?? scanActiveServers;
const stateHome = dependencies.stateHome ?? defaultStateHome();
const saved = readPreviewSession(projectDir, stateHome);
const server = matchingServer(await scan(saved?.port ?? startPort), projectDir);
if (server) {
const pid = Number(server.pid ?? saved?.pid);
if (Number.isInteger(pid) && pid > 0) {
return {
pid,
port: server.port,
projectDir: resolve(projectDir),
logPath: saved?.logPath ?? previewLogPath(projectDir, stateHome),
};
}
}
removePreviewSession(projectDir, stateHome);
return null;
}
export async function startBackgroundPreview(
projectDir: string,
startPort: number,
dependencies: LifecycleDependencies = {},
): Promise<
| { type: "reused"; port: number; pid: number | null; logPath: string | null }
| { type: "started"; port: number; pid: number; logPath: string }
> {
const scan = dependencies.scan ?? scanActiveServers;
const existing = matchingServer(await scan(startPort), projectDir);
if (existing && !dependencies.forceNew) {
return {
type: "reused",
port: existing.port,
pid: existing.pid ? Number(existing.pid) : null,
logPath: null,
};
}
const stateHome = dependencies.stateHome ?? defaultStateHome();
const { pid, logPath } = spawnDetachedPreview(projectDir, stateHome, dependencies);
const sleep = dependencies.sleep ?? delay;
for (let attempt = 0; attempt < 50; attempt++) {
const server = startedServer(
await scan(startPort),
projectDir,
existing,
dependencies.forceNew === true,
);
if (server) {
const session = {
pid,
port: server.port,
projectDir: resolve(projectDir),
logPath,
};
writePreviewSession(session, stateHome);
return { type: "started", ...session };
}
await sleep(200);
}
(dependencies.kill ?? stopProcess)(pid);
throw new Error(`background preview did not become ready; see ${logPath}`);
}
export async function stopBackgroundPreview(
projectDir: string,
startPort: number,
dependencies: LifecycleDependencies = {},
): Promise<boolean> {
const scan = dependencies.scan ?? scanActiveServers;
const stateHome = dependencies.stateHome ?? defaultStateHome();
const saved = readPreviewSession(projectDir, stateHome);
const scanStart = saved?.port ?? startPort;
const server = matchingServer(await scan(scanStart), projectDir);
// A saved PID can be reused after a crashed preview, so only trust it while
// a currently reachable server proves this exact project is still running.
const pid = Number(server ? (server.pid ?? saved?.pid) : undefined);
if (!Number.isInteger(pid) || pid <= 0) {
removePreviewSession(projectDir, stateHome);
return false;
}
(dependencies.kill ?? stopProcess)(pid);
const sleep = dependencies.sleep ?? delay;
for (let attempt = 0; attempt < 25; attempt++) {
if (!matchingServer(await scan(scanStart), projectDir)) {
removePreviewSession(projectDir, stateHome);
return true;
}
await sleep(100);
}
throw new Error(`background preview did not stop for ${resolve(projectDir)}`);
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { parseUpdateTarget } from "./publish.js";
describe("parseUpdateTarget", () => {
it("extracts the id from a full published URL", () => {
expect(parseUpdateTarget("https://hyperframes.dev/p/hfp_abc123")).toBe("hfp_abc123");
});
it("handles a scheme-less URL (which new URL() rejects)", () => {
expect(parseUpdateTarget("hyperframes.dev/p/hfp_abc123")).toBe("hfp_abc123");
});
it("strips a trailing query and hash", () => {
expect(parseUpdateTarget("https://hyperframes.dev/p/hfp_abc123?claim_token=x#frag")).toBe(
"hfp_abc123",
);
});
it("accepts a bare id unchanged and trims surrounding whitespace", () => {
expect(parseUpdateTarget(" hfp_abc123 ")).toBe("hfp_abc123");
});
it("falls back to the last path segment for a non-/p/ URL", () => {
expect(parseUpdateTarget("https://example.com/foo/hfp_abc123")).toBe("hfp_abc123");
});
});
+137 -11
View File
@@ -1,6 +1,5 @@
import { resolve } from "node:path";
import { join, relative, resolve } from "node:path";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { defineCommand } from "citty";
import * as clack from "@clack/prompts";
@@ -9,14 +8,39 @@ import { c } from "../ui/colors.js";
import { lintProject } from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js";
import { publishProjectArchive } from "../utils/publishProject.js";
import { tryResolveCredential } from "../auth/index.js";
import {
ensureProjectId,
readProjectLink,
readTeamProject,
writeTeamProject,
} from "../utils/projectLink.js";
export const examples: Example[] = [
["Publish the current project with a public URL", "hyperframes publish"],
["Publish a specific directory", "hyperframes publish ./my-video"],
["Make the claimed project public to anyone", "hyperframes publish --public"],
["Update an existing published project in place", "hyperframes publish --update <url|id>"],
["Publish to a shared team space", "hyperframes publish --space <space-id>"],
["Skip the consent prompt (scripts)", "hyperframes publish --yes"],
];
/** Extract a project id from a published URL (with or without scheme, query, or hash) or accept a bare id. */
export function parseUpdateTarget(value: string): string {
const trimmed = value.trim();
// Pull the id straight out of a `/p/<id>` path — works for full URLs, scheme-less URLs
// (which `new URL` rejects), and links carrying `?query`/`#hash`.
const pathMatch = trimmed.match(/\/p\/([^/?#]+)/);
if (pathMatch?.[1]) return pathMatch[1];
try {
const segment = new URL(trimmed).pathname.split("/").filter(Boolean).pop();
if (segment) return segment;
} catch {
// Not a URL — treat as a bare id.
}
return trimmed;
}
export default defineCommand({
meta: {
name: "publish",
@@ -35,6 +59,14 @@ export default defineCommand({
description: "Make the claimed project public to anyone, not just the claimer",
default: false,
},
update: {
type: "string",
description: "Update an existing published project in place (its URL or id)",
},
space: {
type: "string",
description: "Publish into a shared team space (its id) so teammates update one link",
},
},
async run({ args }) {
const rawArg = args.dir;
@@ -67,25 +99,119 @@ export default defineCommand({
}
}
const updateTarget =
typeof args.update === "string" && args.update.trim()
? parseUpdateTarget(args.update)
: undefined;
const spaceOverride =
typeof args.space === "string" && args.space.trim() ? args.space.trim() : undefined;
// --update / --space only take effect for an authenticated owner. Fail loudly rather
// than silently minting a fresh URL — the exact failure mode this feature removes.
if (updateTarget || spaceOverride) {
const credential = await tryResolveCredential();
if (!credential) {
console.log();
console.log(
` ${c.error(`${updateTarget ? "--update" : "--space"} requires authentication. Run 'hyperframes auth login' first.`)}`,
);
console.log();
process.exitCode = 1;
return;
}
}
const committedTeam = readTeamProject(dir);
// Stable id: explicit --update wins, else the committed team id, else this machine's stored/minted id.
const requestedProjectId = updateTarget ?? committedTeam?.projectId ?? ensureProjectId(dir);
// Team space: explicit --space wins, else the committed space id, else the personal space.
const spaceId = spaceOverride ?? committedTeam?.spaceId;
// Continuity cue: if this directory was published before, show where it lives so the
// user knows a re-publish updates that same link (when logged in).
const priorLink = readProjectLink(dir);
if (priorLink?.url) {
console.log();
console.log(` ${c.dim(`Previously published at ${priorLink.url}`)}`);
}
clack.intro(c.bold("hyperframes publish"));
const publishSpinner = clack.spinner();
publishSpinner.start("Uploading project...");
try {
const published = await publishProjectArchive(dir, { public: args.public === true });
const claimUrl = new URL(published.url);
claimUrl.searchParams.set("claim_token", published.claimToken);
const published = await publishProjectArchive(dir, {
public: args.public === true,
projectId: requestedProjectId,
spaceId,
});
publishSpinner.stop(c.success("Project published"));
console.log();
console.log(` ${c.dim("Project")} ${c.accent(published.title)}`);
console.log(` ${c.dim("Files")} ${String(published.fileCount)}`);
console.log(` ${c.dim("Public")} ${c.accent(claimUrl.toString())}`);
console.log();
console.log(
` ${c.dim("Open the URL on hyperframes.dev to claim the project and continue editing.")}`,
);
console.log();
if (published.claimed) {
// The server returns the same id on an in-place update, a fresh id on create.
const updatedInPlace = published.projectId === requestedProjectId;
console.log(` ${c.dim("URL")} ${c.accent(published.url)}`);
console.log(
` ${c.dim("Status")} ${c.accent(updatedInPlace ? "Updated existing project" : "Created new project")}`,
);
// Warn whenever we aimed at a KNOWN existing project (an explicit --update target or
// a committed team id) but the server created a fresh one instead — so a teammate
// whose space doesn't own the committed project doesn't silently lose the shared link.
if ((updateTarget || committedTeam) && !updatedInPlace) {
const targetDesc = updateTarget ? "The requested project" : "The committed team project";
console.log();
console.log(
` ${c.dim(`${targetDesc} was not updated (not found, or your space can't access it); a new project was created above instead.`)}`,
);
}
// Persist a committable descriptor so a team converges on this link. This is a
// convenience: wrap it so a read-only project dir can't turn a successful publish
// into a "Publish failed" (the outer catch owns publish failures only).
if (
committedTeam === null ||
(spaceId !== undefined && committedTeam.spaceId !== spaceId)
) {
try {
const file = writeTeamProject(dir, { projectId: published.projectId, spaceId });
console.log();
console.log(
` ${c.dim(`Wrote ${relative(dir, file) || file} — commit it so your team publishes to this link.`)}`,
);
} catch {
// Convenience file only; never shadow a successful publish with a local write failure.
}
}
console.log();
} else {
const claimUrl = new URL(published.url);
claimUrl.searchParams.set("claim_token", published.claimToken);
console.log(` ${c.dim("Public")} ${c.accent(claimUrl.toString())}`);
console.log();
if (updateTarget || spaceOverride) {
// The pre-publish gate saw a credential, but the server didn't accept it (expired
// or invalid) and fell back to anonymous — say so loudly instead of pretending the
// requested update happened.
console.log(
` ${c.error(`Your login looks expired or invalid, so ${updateTarget ? "--update" : "--space"} was ignored and a NEW url was created above.`)}`,
);
console.log(
` ${c.dim("Run 'hyperframes auth login' again, then re-publish to update in place.")}`,
);
} else {
console.log(
` ${c.dim("Open the URL on hyperframes.dev to claim the project and continue editing.")}`,
);
console.log();
console.log(
` ${c.dim("Tip: run 'hyperframes auth login' first for a stable link you can re-publish to.")}`,
);
}
console.log();
}
return;
} catch (err: unknown) {
publishSpinner.stop(c.error("Publish failed"));
+122 -1
View File
@@ -72,6 +72,15 @@ const preflightState = vi.hoisted(() => ({
},
}));
const ffmpegEncoderState = vi.hoisted(() => ({
mode: "software" as "software" | "gpu",
error: null as Error | null,
}));
const orphanCleanupState = vi.hoisted(() => ({
calls: 0,
killed: 0,
}));
vi.mock("../utils/producer.js", () => ({
loadProducer: vi.fn(async () => ({
resolveConfig: vi.fn((overrides: Record<string, unknown>) => {
@@ -80,7 +89,7 @@ vi.mock("../utils/producer.js", () => ({
}),
createRenderJob: vi.fn((config: Record<string, unknown>) => {
producerState.createdJobs.push(config);
return { config, progress: 100 };
return { config, progress: 100, outcome: "completed", warnings: [] };
}),
executeRenderJob: vi.fn(async (job: Record<string, unknown>) => producerState.executeImpl(job)),
})),
@@ -120,6 +129,10 @@ vi.mock("../telemetry/events.js", () => ({
}));
vi.mock("../browser/ffmpeg.js", () => ({
detectH264EncoderMode: vi.fn(() => {
if (ffmpegEncoderState.error) throw ffmpegEncoderState.error;
return ffmpegEncoderState.mode;
}),
findFFmpeg: vi.fn(() => "/usr/bin/ffmpeg"),
getFFmpegInstallHint: vi.fn(() => "brew install ffmpeg"),
}));
@@ -128,6 +141,13 @@ vi.mock("../browser/preflight.js", () => ({
runEnvironmentChecks: vi.fn(async () => preflightState.result),
}));
vi.mock("../utils/orphanCleanup.js", () => ({
killOrphanedProcesses: vi.fn(() => {
orphanCleanupState.calls += 1;
return orphanCleanupState.killed;
}),
}));
describe("renderLocal browser GPU config", () => {
const savedEnv = new Map<string, string | undefined>();
// Pre-resolve once. The first dynamic `import("./render.js")` in this file
@@ -173,6 +193,10 @@ describe("renderLocal browser GPU config", () => {
configState.writeConfigCalls = [];
trackingState.shouldTrack = true;
trackingState.renderObservations = [];
ffmpegEncoderState.mode = "software";
ffmpegEncoderState.error = null;
orphanCleanupState.calls = 0;
orphanCleanupState.killed = 0;
resetTrialState();
savedEnv.clear();
savedEnv.set("HYPERFRAMES_FFMPEG_PATH", process.env.HYPERFRAMES_FFMPEG_PATH);
@@ -185,6 +209,22 @@ describe("renderLocal browser GPU config", () => {
delete process.env.HF_DE_PARALLEL_ROUTER;
});
it("cleans orphaned browser trees before starting a local render", async () => {
orphanCleanupState.killed = 1;
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
});
expect(orphanCleanupState.calls).toBe(1);
});
afterEach(() => {
for (const [key, value] of savedEnv) {
if (value === undefined) {
@@ -323,6 +363,58 @@ describe("renderLocal browser GPU config", () => {
expect(process.env.PRODUCER_HEADLESS_SHELL_PATH).toBe("/mock/chrome");
});
it("falls back to hardware encoding when FFmpeg omits libx264", async () => {
ffmpegEncoderState.mode = "gpu";
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "high",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "force-sdr",
quiet: true,
});
expect(producerState.createdJobs[0]?.useGpu).toBe(true);
});
it("lets the encoder surface its own error when capability detection fails", async () => {
ffmpegEncoderState.error = new Error("encoder probe timed out");
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "high",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "force-sdr",
quiet: true,
});
expect(producerState.createdJobs[0]?.useGpu).toBe(false);
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("encoder probe timed out"));
});
it("diagnoses advisory encoder probe failures unless quiet", async () => {
ffmpegEncoderState.error = new Error("encoder probe timed out");
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "high",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "force-sdr",
quiet: false,
});
expect(producerState.createdJobs[0]?.useGpu).toBe(false);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("encoder probe timed out"));
});
it("resolves browser GPU from CLI flags, Docker mode, and env fallback", () => {
// Default (no flag, no env): auto — engine probes and chooses.
expect(resolveBrowserGpuForCli(false, undefined, undefined)).toBe("auto");
@@ -413,6 +505,35 @@ describe("renderLocal browser GPU config", () => {
expect(producerState.createdJobs[0]?.debug).toBe(true);
});
it("defaults to best-effort readiness", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
});
expect(producerState.createdJobs[0]?.strictness).toBe("best-effort");
});
it("forwards an explicit strict readiness opt-in", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
bestEffort: false,
});
expect(producerState.createdJobs[0]?.strictness).toBe("strict");
});
it("omits variables from createRenderJob when not provided", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
+67 -6
View File
@@ -76,8 +76,11 @@ import { VERSION } from "../version.js";
import { isDevMode } from "../utils/env.js";
import { buildDockerRunArgs, resolveDockerPlatform } from "../utils/dockerRunArgs.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { formatRenderOutputTimestamp } from "@hyperframes/core";
import { runEnvironmentChecks } from "../browser/preflight.js";
import { detectH264EncoderMode } from "../browser/ffmpeg.js";
import { chromeLaunchRemediation } from "../browser/linuxDeps.js";
import { killOrphanedProcesses } from "../utils/orphanCleanup.js";
import type { ProducerLogger, RenderJob } from "@hyperframes/producer";
import {
MAX_VP9_CPU_USED,
@@ -267,6 +270,12 @@ export default defineCommand({
"Write full render diagnostics and keep intermediate artifacts under the producer .debug directory.",
default: false,
},
"best-effort": {
type: "boolean",
description:
"Allow output with structured capture-readiness warnings (default). Use --no-best-effort to fail on missing or unready media.",
default: true,
},
strict: {
type: "boolean",
description: "Fail render on lint errors",
@@ -602,16 +611,14 @@ export default defineCommand({
// ── Resolve output path ───────────────────────────────────────────────
const rendersDir = resolve("renders");
const ext = FORMAT_EXT[format] ?? ".mp4";
// fallow-ignore-next-line code-duplication
const now = new Date();
const datePart = now.toISOString().slice(0, 10);
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
const timestamp = formatRenderOutputTimestamp(now);
const batchOutputTemplate = args.output
? args.output
: join(rendersDir, `${project.name}_${datePart}_${timePart}_{index}${ext}`);
: join(rendersDir, `${project.name}_${timestamp}_{index}${ext}`);
const outputPath = args.output
? resolve(args.output)
: join(rendersDir, `${project.name}_${datePart}_${timePart}${ext}`);
: join(rendersDir, `${project.name}_${timestamp}${ext}`);
// Ensure output directory exists
if (!batchPath) mkdirSync(dirname(outputPath), { recursive: true });
@@ -622,6 +629,7 @@ export default defineCommand({
const browserGpuMode = resolveBrowserGpuForCli(useDocker, browserGpuArg);
const quiet = args.quiet ?? false;
const debug = args.debug ?? false;
const bestEffort = args["best-effort"] ?? true;
const batchJson = args.json ?? false;
const effectiveQuiet = quiet || (batchPath != null && batchJson);
const strictAll = args["strict-all"] ?? false;
@@ -902,6 +910,7 @@ export default defineCommand({
protocolTimeout,
playerReadyTimeout,
debug,
bestEffort,
exitAfterComplete: false,
throwOnError: true,
skipFeedback: true,
@@ -960,6 +969,7 @@ export default defineCommand({
videoFrameFormat,
quiet,
debug,
bestEffort,
variables,
entryFile,
outputResolution,
@@ -988,6 +998,7 @@ export default defineCommand({
quiet,
browserPath,
debug,
bestEffort,
variables,
entryFile,
outputResolution,
@@ -1006,6 +1017,8 @@ export default defineCommand({
export interface SingleRenderResult {
durationMs?: number;
renderTimeMs: number;
outcome?: "completed" | "completed_with_warnings";
warnings?: Array<{ code: string; message: string }>;
}
export function renderLintContinuationHint(strictErrors: boolean): string {
@@ -1036,6 +1049,7 @@ interface RenderOptions {
videoFrameFormat?: VideoFrameFormat;
quiet: boolean;
debug?: boolean;
bestEffort?: boolean;
browserPath?: string;
variables?: Record<string, unknown>;
entryFile?: string;
@@ -1370,6 +1384,7 @@ async function renderDocker(
outputResolution: options.outputResolution,
pageSideCompositing: options.pageSideCompositing,
debug: options.debug,
bestEffort: options.bestEffort,
experimentalFastCapture: options.experimentalFastCapture,
pageNavigationTimeoutMs: options.pageNavigationTimeoutMs,
},
@@ -1425,8 +1440,18 @@ export async function renderLocal(
outputPath: string,
options: RenderOptions,
): Promise<SingleRenderResult> {
const recoveredOrphanTrees = killOrphanedProcesses();
if (recoveredOrphanTrees > 0 && !options.quiet) {
console.warn(
c.warn(
` Recovered ${recoveredOrphanTrees} orphaned browser process ${recoveredOrphanTrees === 1 ? "tree" : "trees"} from an interrupted render.`,
),
);
}
const preflight = await runEnvironmentChecks({
projectDir,
diskPaths: [tmpdir(), dirname(outputPath)],
browserPath: options.browserPath,
includeBrowser: true,
includeDisk: true,
@@ -1454,6 +1479,26 @@ export async function renderLocal(
process.env.PRODUCER_HEADLESS_SHELL_PATH = preflight.browser.executablePath;
}
if (!options.gpu && options.format === "mp4" && preflight.ffmpegPath) {
let encoderMode: ReturnType<typeof detectH264EncoderMode> = "software";
try {
encoderMode = detectH264EncoderMode(preflight.ffmpegPath, false);
} catch (error) {
// Capability probing is advisory. Let the real encode surface the
// authoritative FFmpeg error instead of failing here with a bare stack.
if (!options.quiet) {
const detail = error instanceof Error ? error.message : String(error);
console.warn(c.warn(` Unable to probe H.264 encoder capabilities: ${detail}`));
}
}
if (encoderMode === "gpu") {
console.warn(
c.warn(" FFmpeg does not include libx264; falling back to VideoToolbox H.264 encoding."),
);
options = { ...options, gpu: true };
}
}
const producer = await loadProducer();
const deParallelRouterTrialArmed = maybeEnableDeParallelRouterTrial(
options.quiet,
@@ -1490,6 +1535,7 @@ export async function renderLocal(
entryFile: options.entryFile,
outputResolution: options.outputResolution,
debug: options.debug,
strictness: options.bestEffort === false ? "strict" : "best-effort",
});
const onProgress = options.quiet
@@ -1515,6 +1561,11 @@ export async function renderLocal(
maybeConsumeDeParallelRouterTrial(deParallelRouterTrialArmed, job, options.quiet);
const elapsed = Date.now() - startTime;
if (job.outcome === "completed_with_warnings") {
for (const warning of job.warnings) {
console.warn(c.warn(` [${warning.code}] ${warning.message}`));
}
}
trackRenderMetrics(job, elapsed, options, false);
printRenderComplete(
outputPath,
@@ -1534,7 +1585,14 @@ export async function renderLocal(
const durationMs = job.perfSummary
? Math.round(job.perfSummary.compositionDurationSeconds * 1000)
: undefined;
return { renderTimeMs: elapsed, durationMs };
const outcome =
job.outcome === "completed_with_warnings" ? "completed_with_warnings" : "completed";
return {
renderTimeMs: elapsed,
durationMs,
outcome,
warnings: job.warnings.map((warning) => ({ code: warning.code, message: warning.message })),
};
}
type UnrefableTimer = {
@@ -2005,6 +2063,9 @@ function trackRenderMetrics(
deVerifyInitMs: perf?.drawElement?.verifyInitMs,
deSelfVerifyFallback: perf?.drawElement?.selfVerifyFallback,
deFallbackReason: perf?.drawElement?.fallbackReason,
deFallbackFailedDb: perf?.drawElement?.fallbackFailedDb,
deFallbackFrameIndex: perf?.drawElement?.fallbackFrameIndex,
deFallbackThresholdDb: perf?.drawElement?.fallbackThresholdDb,
deBlankSuspects: perf?.drawElement?.blankSuspects,
deBlankDeterministicAccepts: perf?.drawElement?.blankDeterministicAccepts,
deBlankRecaptures: perf?.drawElement?.blankRecaptures,
+84 -3
View File
@@ -112,6 +112,15 @@ vi.mock("../utils/skillsMirror.js", () => ({
mirrorGlobalSkills: vi.fn(() => ({ source: null, mirrored: [] })),
}));
// The reconcile commands drop the background nudge's cached verdict on
// success (the stale-24h-cache fix). Stub it so these tests never touch the
// dev machine's real ~/.hyperframes config; the invalidation behavior itself
// is unit-tested in skillsUpdateCheck.test.ts.
const invalidateSkillsCache = vi.fn();
vi.mock("../utils/skillsUpdateCheck.js", () => ({
invalidateSkillsCache: (...args: unknown[]) => invalidateSkillsCache(...args),
}));
// The global install command this CLI runs (after `skills add <url>` and the
// per-name `--skill` selection).
const GLOBAL_ARGS_TAIL = [
@@ -133,7 +142,7 @@ function setPlatform(platform: NodeJS.Platform): void {
/** Invoke a `skills <name>` subcommand from a freshly-imported module. */
async function runSkillsSub(
name: "update",
name: "update" | "check",
args: Record<string, unknown> = {},
positionals: string[] = [],
): Promise<void> {
@@ -175,11 +184,19 @@ describe("hyperframes skills", () => {
vi.resetModules();
// vi.resetModules re-imports skills.js but the manifest mock's vi.fn
// instances persist — restore their default behavior for each test.
const { checkSkills, presentSkills } = await import("../utils/skillsManifest.js");
// pruneOrphanedLockEntries is reset explicitly too: relying on afterEach's
// vi.restoreAllMocks() to clear vi.fn() call state is vitest-3-specific
// (vitest 4 restores spies only), and call-count assertions like the
// twice-in-a-row convergence test would then see counts accumulated from
// earlier tests.
const { checkSkills, presentSkills, pruneOrphanedLockEntries } =
await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockReset();
vi.mocked(checkSkills).mockImplementation(async () => DEFAULT_CHECK as never);
vi.mocked(presentSkills).mockReset();
vi.mocked(presentSkills).mockImplementation((names: readonly string[]) => [...names]);
vi.mocked(pruneOrphanedLockEntries).mockReset();
vi.mocked(pruneOrphanedLockEntries).mockImplementation(() => []);
// Each test asserts on process.exitCode; isolate it from the runner's own.
prevExitCode = process.exitCode;
process.exitCode = 0;
@@ -570,6 +587,65 @@ describe("hyperframes skills", () => {
expect(state.spawnCalls).toHaveLength(0);
expect(process.exitCode).toBe(1);
});
// The stale-24h-cache regression: the skills commands are excluded from the
// background nudge pipeline (cli.ts), so unless they drop the cached verdict
// themselves, a successful install keeps the pre-install "N out of date or
// missing" nag alive on every other command for up to 24h.
describe("nudge-cache invalidation", () => {
it("skills update drops the cached nudge verdict on success", async () => {
setPlatform("linux");
invalidateSkillsCache.mockClear();
await runSkillsUpdate();
expect(process.exitCode).toBe(0);
expect(invalidateSkillsCache).toHaveBeenCalled();
});
it("skills update keeps the cached verdict when the install fails", async () => {
setPlatform("linux");
invalidateSkillsCache.mockClear();
state.spawnExitCode = 1; // `skills add` exits non-zero → strict throw
await runSkillsUpdate();
expect(process.exitCode).toBe(1);
expect(invalidateSkillsCache).not.toHaveBeenCalled();
});
it("skills update keeps the cached verdict on the offline (presence-only) path", async () => {
setPlatform("linux");
invalidateSkillsCache.mockClear();
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockRejectedValue(new Error("offline"));
await runSkillsUpdate();
// Presence-only run never learned anything about freshness — the cached
// verdict is still the best information available.
expect(invalidateSkillsCache).not.toHaveBeenCalled();
});
it("bare `skills` (full install) drops the cached nudge verdict", async () => {
setPlatform("linux");
invalidateSkillsCache.mockClear();
const { default: skillsCmd } = await import("./skills.js");
await skillsCmd.run?.({ args: {}, rawArgs: [], cmd: skillsCmd } as never);
expect(invalidateSkillsCache).toHaveBeenCalled();
});
it("skills check drops the cached verdict — the fresh result supersedes it", async () => {
setPlatform("linux");
invalidateSkillsCache.mockClear();
await runSkillsSub("check", { json: true });
expect(invalidateSkillsCache).toHaveBeenCalled();
});
});
});
// The router contract: `/hyperframes` picks a workflow, then runs
@@ -585,11 +661,16 @@ describe("hyperframes skills update <names>", () => {
state.spawnExitCode = 0;
state.gitMissing = false;
vi.resetModules();
const { checkSkills, presentSkills } = await import("../utils/skillsManifest.js");
// Same explicit resets as the describe above (incl. the vitest-4-proofing
// note on pruneOrphanedLockEntries).
const { checkSkills, presentSkills, pruneOrphanedLockEntries } =
await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockReset();
vi.mocked(checkSkills).mockImplementation(async () => DEFAULT_CHECK as never);
vi.mocked(presentSkills).mockReset();
vi.mocked(presentSkills).mockImplementation((names: readonly string[]) => [...names]);
vi.mocked(pruneOrphanedLockEntries).mockReset();
vi.mocked(pruneOrphanedLockEntries).mockImplementation(() => []);
prevExitCode = process.exitCode;
process.exitCode = 0;
});
+19 -1
View File
@@ -16,6 +16,7 @@ import {
type SkillsCheckResult,
} from "../utils/skillsManifest.js";
import { mirrorGlobalSkills } from "../utils/skillsMirror.js";
import { invalidateSkillsCache } from "../utils/skillsUpdateCheck.js";
import { trackSkillsInstallSkipped } from "../telemetry/events.js";
import type { Example } from "./_examples.js";
@@ -368,6 +369,13 @@ export async function updateSkills(
await installSkills(result.installed, { cwd: opts.cwd, strict });
verifyInstalled(result.installed, { strict, cwd: opts.cwd });
}
// The install (or the fresh canonical check confirming everything current)
// supersedes whatever the background nudge cached before it — drop the
// cached verdict so the next command re-checks instead of nagging from the
// pre-install snapshot for up to 24h. Deliberately NOT done on the offline
// (presence-only) path above: that run never learned anything about
// freshness, so the cached verdict is the best information we still have.
invalidateSkillsCache();
return result;
}
@@ -555,6 +563,11 @@ const checkCommand = defineCommand({
if (args.json) console.log(JSON.stringify(withMeta(result), null, 2));
else renderCheck(result);
// This check just displayed a fresh verdict, superseding whatever the
// background nudge cached — invalidate so the next command's nudge agrees
// with what the user was just shown instead of a pre-check snapshot.
invalidateSkillsCache();
// Exit non-zero when installed skills are stale, so agents and CI can gate:
// hyperframes skills check || npx hyperframes skills update
if (result.updateAvailable) process.exitCode = 1;
@@ -748,6 +761,11 @@ export default defineCommand({
// citty runs this parent handler even when a subcommand matches; guard on
// the positional so bare `hyperframes skills` installs, while
// `hyperframes skills check|update` does not also re-install.
if (!args._?.[0]) await installSkills("*");
if (!args._?.[0]) {
await installSkills("*");
// Same as updateSkills: a full install supersedes the background
// nudge's cached pre-install verdict.
invalidateSkillsCache();
}
},
});
+149 -1
View File
@@ -1,5 +1,22 @@
import { describe, expect, it } from "vitest";
import { computeSnapshotTimes, parseZoomScale, tailFrameTime } from "./snapshot.js";
import { readFileSync } from "node:fs";
import {
computeSnapshotTimes,
formatSnapshotTimestamp,
parseZoomScale,
requireSnapshotFfmpeg,
resolveSnapshotVideoFrameTime,
tailFrameTime,
} from "./snapshot.js";
describe("formatSnapshotTimestamp", () => {
it.each([
[1.12, "1.12s"],
[0.30000000000000004, "0.3s"],
])("formats %s without discarding useful precision", (time, expected) => {
expect(formatSnapshotTimestamp(time)).toBe(expected);
});
});
// --zoom's crop-region math (selector bbox + padding + clamp, exact region
// form, no-match error) is owned by and tested in
@@ -21,6 +38,115 @@ describe("tailFrameTime", () => {
});
});
describe("transparent snapshot capture", () => {
it("asks Chrome to retain the alpha channel in review PNGs", () => {
const source = readFileSync(new URL("./snapshot.ts", import.meta.url), "utf8");
expect(source).toContain(
'page.screenshot({ path: framePath, type: "png", omitBackground: true })',
);
});
});
describe("resolveSnapshotVideoFrameTime", () => {
it("keeps media active at the inclusive clip end and samples its last decodable frame", () => {
expect(
resolveSnapshotVideoFrameTime({
globalTime: 15,
clipStart: 0,
clipDuration: 15,
relativeTime: 15,
sourceDuration: 15,
}),
).toBeCloseTo(15 - 1 / 30, 6);
});
it("keeps ordinary in-window media timestamps unchanged", () => {
expect(
resolveSnapshotVideoFrameTime({
globalTime: 7.5,
clipStart: 0,
clipDuration: 15,
relativeTime: 7.5,
sourceDuration: 15,
}),
).toBe(7.5);
});
it("does not activate media after the clip end", () => {
expect(
resolveSnapshotVideoFrameTime({
globalTime: 15.001,
clipStart: 0,
clipDuration: 15,
relativeTime: 15.001,
sourceDuration: 15,
}),
).toBeNull();
});
it.each([
{
name: "before clip start",
input: {
globalTime: 4.9,
clipStart: 5,
clipDuration: 10,
relativeTime: 0,
sourceDuration: 10,
},
expected: null,
},
{
name: "negative relative time",
input: {
globalTime: 5,
clipStart: 5,
clipDuration: 10,
relativeTime: -0.1,
sourceDuration: 10,
},
expected: null,
},
{
name: "unknown source duration",
input: {
globalTime: 15,
clipStart: 5,
clipDuration: 10,
relativeTime: 10,
sourceDuration: 0,
},
expected: 10 - 1 / 30,
},
{
name: "offset clip inclusive end",
input: {
globalTime: 15,
clipStart: 5,
clipDuration: 10,
relativeTime: 10,
sourceDuration: 10,
},
expected: 10 - 1 / 30,
},
{
name: "clip end within floating-point tolerance",
input: {
globalTime: 15 + 5e-10,
clipStart: 5,
clipDuration: 10,
relativeTime: 10,
sourceDuration: 10,
},
expected: 10 - 1 / 30,
},
])("handles $name", ({ input, expected }) => {
const result = resolveSnapshotVideoFrameTime(input);
if (expected === null) expect(result).toBeNull();
else expect(result).toBeCloseTo(expected, 6);
});
});
describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => {
it("default frames: last point is the readable tail, never exact duration", () => {
const { times, appendedTail } = computeSnapshotTimes(8, { frames: 5 });
@@ -62,6 +188,16 @@ describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => {
expect(times).toEqual([1, 2]);
expect(appendedTail).toBe(false);
});
it("preserves exact explicit transition timestamps", () => {
const exactTransition = 3.3666666666666667;
const { times } = computeSnapshotTimes(8, {
frames: 5,
at: [exactTransition],
includeEnd: false,
});
expect(times).toEqual([exactTransition]);
});
});
describe("parseZoomScale (--zoom-scale)", () => {
@@ -79,3 +215,15 @@ describe("parseZoomScale (--zoom-scale)", () => {
expect(parseZoomScale("-1")).toBe(3);
});
});
describe("requireSnapshotFfmpeg", () => {
it("rejects video snapshot extraction when FFmpeg is unavailable", () => {
expect(() => requireSnapshotFfmpeg(undefined)).toThrow(
/FFmpeg is required to extract video frames for snapshots/,
);
});
it("preserves the resolved FFmpeg executable", () => {
expect(requireSnapshotFfmpeg("C:\\tools\\ffmpeg.exe")).toBe("C:\\tools\\ffmpeg.exe");
});
});
+90 -38
View File
@@ -17,7 +17,7 @@ import { resolveProject } from "../utils/project.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
import { c } from "../ui/colors.js";
import { findFFmpeg } from "../browser/ffmpeg.js";
import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js";
import { parseAngle, type Camera } from "./motionShotLayout.js";
import type { Example } from "./_examples.js";
@@ -60,6 +60,45 @@ function orbitStageSource(): string {
* `hyperframes snapshot` indefinitely. */
const FFMPEG_EXTRACT_TIMEOUT_MS = 30_000;
/** Keep millisecond-level snapshot timing proof without leaking floating-point noise. */
export function formatSnapshotTimestamp(time: number): string {
return `${Number(time.toFixed(3))}s`;
}
/** Keep an exact clip-end snapshot aligned with the renderer's inclusive media
* window. This intentionally differs from the live player's exclusive-end
* visibility so an explicit end-boundary review does not become blank. FFmpeg
* cannot decode at a source's exclusive duration, so sample one nominal 30fps
* frame inside the source. This also clamps clips whose configured media window
* extends beyond the source. An infinite clip duration intentionally never
* enters the end-boundary branch. */
export function resolveSnapshotVideoFrameTime(input: {
globalTime: number;
clipStart: number;
clipDuration: number;
relativeTime: number;
sourceDuration: number;
}): number | null {
const { globalTime, clipStart, clipDuration, relativeTime, sourceDuration } = input;
const clipEnd = clipStart + clipDuration;
const clipEndTolerance = 1e-9;
if (globalTime < clipStart || globalTime > clipEnd + clipEndTolerance || relativeTime < 0)
return null;
const atClipEnd = Math.abs(globalTime - clipEnd) <= clipEndTolerance;
if (!atClipEnd) return relativeTime;
const sourceEnd = sourceDuration > 0 ? sourceDuration : relativeTime;
return Math.max(0, Math.min(relativeTime, sourceEnd - 1 / 30));
}
export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string {
if (ffmpegPath) return ffmpegPath;
throw new Error(
`FFmpeg is required to extract video frames for snapshots. ${getFFmpegInstallHint()}`,
);
}
/**
* Extract a single frame from a video file at `timeSeconds` via FFmpeg.
* Used to work around Chrome-headless's inability to reliably seek
@@ -73,8 +112,7 @@ async function extractVideoFrameToBuffer(
const tmp = mkdtempSync(join(tmpdir(), "hf-snapshot-frame-"));
const outPath = join(tmp, "frame.png");
try {
const ffmpegPath = findFFmpeg();
if (!ffmpegPath) return null;
const ffmpegPath = requireSnapshotFfmpeg(findFFmpeg());
// `-ss` before `-i` performs a fast keyframe seek; adequate for snapshot accuracy
// (±1 frame) and orders of magnitude faster than the decode-and-scan alternative.
const args = ["-hide_banner", "-loglevel", "error"];
@@ -159,7 +197,11 @@ export function computeSnapshotTimes(
const round = (t: number) => Math.round(t * 1000) / 1000;
if (opts.at?.length) {
const times = opts.at.map(round);
// `--at` is an evidence contract: callers may pass exact fractional-frame
// boundaries (for example 101 / 30). Do not normalize their requested
// positions; rounding to milliseconds can move a transition sample to the
// other side of the boundary.
const times = [...opts.at];
// Only append if the user didn't already sample at/near the readable tail.
const hasTail = times.some((t) => Math.abs(t - tail) < 0.05 || t >= duration);
if (includeEnd && duration > 0 && !hasTail) {
@@ -355,38 +397,48 @@ async function captureSnapshots(
if (cameraExpr) await page.evaluate(cameraExpr);
if (injectVideoFramesBatch && syncVideoFrameVisibility) {
const active = await page.evaluate((t: number) => {
return Array.from(document.querySelectorAll("video[data-start]"))
.map((el) => {
const v = el as HTMLVideoElement;
const start = parseFloat(v.dataset.start ?? "0") || 0;
const rawRate = v.defaultPlaybackRate;
const playbackRate =
Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1;
const mediaStart =
parseFloat(v.dataset.playbackStart ?? v.dataset.mediaStart ?? "0") || 0;
const rawDuration = parseFloat(v.dataset.duration ?? "");
const srcDur = Number.isFinite(v.duration) && v.duration > 0 ? v.duration : 0;
const duration =
Number.isFinite(rawDuration) && rawDuration > 0
? rawDuration
: srcDur > 0
? Math.max(0, (srcDur - mediaStart) / playbackRate)
: Number.POSITIVE_INFINITY;
let relTime = (t - start) * playbackRate + mediaStart;
if (v.loop && srcDur > mediaStart && relTime >= srcDur) {
relTime = mediaStart + ((relTime - mediaStart) % (srcDur - mediaStart));
}
const activeNow = t >= start && t < start + duration && relTime >= 0 && !!v.id;
return {
id: v.id,
src: v.currentSrc || v.src,
relTime,
active: activeNow,
};
})
.filter((entry) => entry.active && entry.src);
const candidates = await page.evaluate((t: number) => {
return Array.from(document.querySelectorAll("video[data-start]")).map((el) => {
const v = el as HTMLVideoElement;
const start = parseFloat(v.dataset.start ?? "0") || 0;
const rawRate = v.defaultPlaybackRate;
const playbackRate =
Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1;
const mediaStart =
parseFloat(v.dataset.playbackStart ?? v.dataset.mediaStart ?? "0") || 0;
const rawDuration = parseFloat(v.dataset.duration ?? "");
const srcDur = Number.isFinite(v.duration) && v.duration > 0 ? v.duration : 0;
const duration =
Number.isFinite(rawDuration) && rawDuration > 0
? rawDuration
: srcDur > 0
? Math.max(0, (srcDur - mediaStart) / playbackRate)
: Number.POSITIVE_INFINITY;
let relTime = (t - start) * playbackRate + mediaStart;
if (v.loop && srcDur > mediaStart && relTime >= srcDur) {
relTime = mediaStart + ((relTime - mediaStart) % (srcDur - mediaStart));
}
return {
id: v.id,
src: v.currentSrc || v.src,
start,
duration,
srcDuration: srcDur,
relTime,
};
});
}, time);
const active = candidates.flatMap((candidate) => {
if (!candidate.id || !candidate.src) return [];
const frameTime = resolveSnapshotVideoFrameTime({
globalTime: time,
clipStart: candidate.start,
clipDuration: candidate.duration,
relativeTime: candidate.relTime,
sourceDuration: candidate.srcDuration,
});
return frameTime === null ? [] : [{ ...candidate, relTime: frameTime }];
});
const updates: Array<{ videoId: string; dataUri: string }> = [];
for (const v of active) {
@@ -456,7 +508,7 @@ async function captureSnapshots(
}
}
const timeLabel = `${time.toFixed(1)}s`;
const timeLabel = formatSnapshotTimestamp(time);
const filename = `frame-${String(i).padStart(2, "0")}-at-${timeLabel}.png`;
const framePath = join(snapshotDir, filename);
@@ -481,7 +533,7 @@ async function captureSnapshots(
);
writeFileSync(framePath, buffer);
} else {
await page.screenshot({ path: framePath, type: "png" });
await page.screenshot({ path: framePath, type: "png", omitBackground: true });
}
const rel = relative(projectDir, framePath);
savedPaths.push(rel.startsWith("..") || isAbsolute(rel) ? framePath : rel);
@@ -579,7 +631,7 @@ export default defineCommand({
const zoomScale = parseZoomScale(args["zoom-scale"]);
const label = atTimestamps
? `${atTimestamps.length} frames at [${atTimestamps.map((t) => t.toFixed(1) + "s").join(", ")}]`
? `${atTimestamps.length} frames at [${atTimestamps.map(formatSnapshotTimestamp).join(", ")}]`
: `${frames} frames`;
const angleLabel =
camera && (camera.yaw !== 0 || camera.pitch !== 0)
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
vi.mock("../utils/updateCheck.js", async (orig) => ({
...(await orig<typeof import("../utils/updateCheck.js")>()),
checkForUpdate: vi.fn(async () => ({
current: "0.7.48",
latest: "0.7.55",
updateAvailable: true,
})),
}));
import { upgradeProjectPins } from "./upgrade.js";
describe("upgradeProjectPins", () => {
const dirs: string[] = [];
afterEach(() => dirs.forEach((d) => rmSync(d, { recursive: true, force: true })));
function project(scripts: Record<string, string>): string {
const d = mkdtempSync(join(tmpdir(), "hf-proj-"));
dirs.push(d);
writeFileSync(join(d, "package.json"), JSON.stringify({ name: "x", scripts }, null, 2));
return d;
}
it("rewrites pinned scripts to latest and reports the delta", async () => {
const d = project({ render: "npx --yes hyperframes@0.7.48 render" });
const r = await upgradeProjectPins(d, { json: false, check: false });
expect(r.changed).toBe(true);
expect(r.from).toEqual(["0.7.48"]);
expect(r.to).toBe("0.7.55");
const pkg = JSON.parse(readFileSync(join(d, "package.json"), "utf-8"));
expect(pkg.scripts.render).toBe("npx --yes hyperframes@0.7.55 render");
});
it("--check reports without writing", async () => {
const d = project({ render: "npx --yes hyperframes@0.7.48 render" });
const before = readFileSync(join(d, "package.json"), "utf-8");
const r = await upgradeProjectPins(d, { json: false, check: true });
expect(r.changed).toBe(true);
expect(readFileSync(join(d, "package.json"), "utf-8")).toBe(before);
});
});
+64
View File
@@ -2,12 +2,16 @@ import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import * as clack from "@clack/prompts";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
import { resolve } from "node:path";
import { c } from "../ui/colors.js";
import { rewriteProjectPinnedScripts } from "../utils/projectPin.js";
export const examples: Example[] = [
["Check for updates interactively", "hyperframes upgrade"],
["Check for updates without prompting", "hyperframes upgrade --check"],
["Upgrade non-interactively", "hyperframes upgrade --yes"],
["Bump a project's pinned CLI scripts", "hyperframes upgrade --project"],
];
import { VERSION } from "../version.js";
import {
@@ -24,12 +28,28 @@ export default defineCommand({
yes: { type: "boolean", alias: "y", description: "Show upgrade commands without prompting" },
check: { type: "boolean", description: "Check for updates and exit (no prompt)" },
json: { type: "boolean", description: "Output as JSON", default: false },
project: {
type: "string",
description:
"Bump this project's package.json hyperframes@<version> script pins to latest (default: current dir)",
},
},
// fallow-ignore-next-line complexity
async run({ args }) {
const useJson = args.json === true;
const checkOnly = args.check === true;
if (args.project !== undefined) {
const dir = typeof args.project === "string" && args.project.length ? args.project : ".";
const res = await upgradeProjectPins(resolve(dir), { json: useJson, check: checkOnly });
if (useJson) {
console.log(JSON.stringify(withMeta(res), null, 2));
return;
}
printProjectPinResult(res, checkOnly);
return;
}
// JSON mode: always force-check and output structured data
if (useJson) {
const result = await checkForUpdate(true);
@@ -137,6 +157,50 @@ function printManualCommands(displayCmd: string, npxFallback: string): void {
clack.outro(c.success("Run one of the commands above to upgrade."));
}
export async function upgradeProjectPins(
dir: string,
opts: { json: boolean; check: boolean },
): Promise<{ changed: boolean; from: string[]; to: string; path: string }> {
const pkgPath = resolve(dir, "package.json");
if (!existsSync(pkgPath)) {
return { changed: false, from: [], to: "", path: pkgPath };
}
const raw = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
scripts?: Record<string, string>;
};
const scripts = raw.scripts ?? {};
const { latest } = await checkForUpdate(true);
if (!isSafeVersion(latest)) return { changed: false, from: [], to: latest, path: pkgPath };
const rewrite = rewriteProjectPinnedScripts(scripts, latest);
if (rewrite.changed && !opts.check) {
raw.scripts = rewrite.scripts;
const tmp = `${pkgPath}.tmp`;
writeFileSync(tmp, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
renameSync(tmp, pkgPath);
}
return { changed: rewrite.changed, from: rewrite.fromVersions, to: latest, path: pkgPath };
}
function printProjectPinResult(
res: { changed: boolean; from: string[]; to: string; path: string },
checkOnly: boolean,
): void {
if (!existsSync(res.path)) {
console.log(` ${c.dim("No package.json found — nothing to upgrade.")}`);
return;
}
if (!res.changed) {
console.log(` ${c.success("◇")} Project scripts already on hyperframes@${res.to}`);
return;
}
const verb = checkOnly ? "would bump" : "bumped";
console.log(
` ${c.success("◇")} ${verb} project scripts ${res.from.join(", ")} → ${c.accent(res.to)}`,
);
if (checkOnly)
console.log(` ${c.dim("Run `npx hyperframes@latest upgrade --project` to apply.")}`);
}
export function runDetectedInstall(
invocation: { bin: string; args: string[] },
displayCmd: string,
@@ -11,6 +11,7 @@ import {
runAndParseJsonEnvelope,
} from "./deprecationTestHarness.js";
import {
auditClipDurations,
extractCompositionErrorsFromLint,
navigationTimeoutHint,
raceMediaReady,
@@ -49,6 +50,58 @@ vi.mock("../utils/producer.js", () => ({
vi.mock("../utils/project.js", () => resolveProjectMock());
vi.mock("../utils/lintProject.js", () => lintProjectFailureMock());
describe("auditClipDurations", () => {
it("audits audio only because explicit video slots hold their final frame", async () => {
let selector = "";
const originalDocument = globalThis.document;
const audio = {
duration: 1,
id: "voice",
loop: false,
tagName: "AUDIO",
getAttribute: (name: string) =>
name === "data-duration" ? "5" : name === "data-media-start" ? "0" : null,
};
const page = {
evaluate: async (fn: (waitMs: number) => unknown, waitMs: number) => {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
querySelectorAll: (query: string) => {
selector = query;
return [audio];
},
},
});
return fn(waitMs);
},
};
try {
const warnings = await auditClipDurations(
page as never,
({ slotSeconds, mediaSeconds }) => ({
shortfallSeconds: slotSeconds - mediaSeconds,
toleranceSeconds: 0.05,
}),
10,
);
expect(selector).toBe("audio[data-duration]");
expect(warnings).toHaveLength(1);
expect(warnings[0]?.text).toContain('Audio "voice"');
} finally {
if (originalDocument === undefined) {
Reflect.deleteProperty(globalThis, "document");
} else {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: originalDocument,
});
}
}
});
});
// Regression for the validate audio-duration-probe timeout: a slow-loading
// media element's duration was snapshotted once, at a fixed point in time,
// and any element still mid-load was permanently misreported as unreadable.
+9 -4
View File
@@ -127,8 +127,9 @@ export function raceMediaReady(
}
/**
* Flag `<video>`/`<audio>` clips whose source is meaningfully shorter than their
* `data-duration` slot (the slot gets silently shortened in renders). Runs in
* Flag `<audio>` clips whose source is meaningfully shorter than their
* `data-duration` slot (the slot gets silently shortened in renders). Videos
* intentionally hold their final frame through an explicit longer slot. Runs in
* the live page to read each element's intrinsic `.duration`, which static lint
* can't see.
*/
@@ -140,7 +141,7 @@ export async function auditClipDurations(
// fallow-ignore-next-line complexity
const clips = await page.evaluate(async (maxWaitMs: number) => {
const nodes = Array.from(
document.querySelectorAll("video[data-duration], audio[data-duration]"),
document.querySelectorAll("audio[data-duration]"),
) as HTMLMediaElement[];
// The caller's page-settle sleep is a flat, unconditional wait shared with
@@ -276,7 +277,11 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise<Co
: [],
)) as ContrastCandidate[];
const screenshot = (await page.screenshot({ encoding: "base64", type: "png" })) as string;
const screenshot = (await page.screenshot({
encoding: "base64",
type: "png",
omitBackground: true,
})) as string;
const entries = await page.evaluate(
(b64: string, time: number, cands: ContrastCandidate[]) =>
typeof (window as unknown as Record<string, unknown>).__contrastAuditFinish === "function"
+1
View File
@@ -33,6 +33,7 @@ const GROUPS: Group[] = [
title: "Project",
commands: [
["lint", "Validate a composition for common mistakes"],
["check", "Run lint, runtime validation, and layout inspection as one gate"],
[
"validate",
"Runtime-validate a composition in headless Chrome (JS errors, missing assets, contrast)",
@@ -0,0 +1,51 @@
import { readFileSync, readdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const blocksDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../registry/blocks");
interface RegistryManifest {
files: Array<{ path: string; type: string }>;
}
function findMissingLocalScripts(itemDir: string, manifest: RegistryManifest): string[] {
const manifestPaths = new Set(manifest.files.map((file) => file.path));
const missing: string[] = [];
for (const file of manifest.files) {
if (file.type !== "hyperframes:composition" || !file.path.endsWith(".html")) continue;
const html = readFileSync(join(itemDir, file.path), "utf8");
const localScripts = [...html.matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["']/gi)]
.map((match) => match[1] ?? "")
.filter((src) => src && !/^(?:[a-z]+:)?\/\//i.test(src));
for (const src of localScripts) {
if (!manifestPaths.has(src)) missing.push(src);
}
}
return missing;
}
describe("registry block manifests", () => {
it("installs every local script referenced by a block composition", () => {
const missing: string[] = [];
for (const entry of readdirSync(blocksDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const itemDir = join(blocksDir, entry.name);
const manifest = JSON.parse(
readFileSync(join(itemDir, "registry-item.json"), "utf8"),
) as RegistryManifest;
for (const src of findMissingLocalScripts(itemDir, manifest)) {
missing.push(`${entry.name}: ${src}`);
}
}
expect(missing).toEqual([]);
});
});
+5 -1
View File
@@ -97,6 +97,7 @@ export async function testPortOnAllHosts(
interface HyperframesConfigResponse {
isHyperframes: boolean;
pid?: number;
projectName: string;
projectDir: string;
serverBuildSignature?: string | null;
@@ -278,7 +279,10 @@ export async function scanActiveServers(startPort = 3002): Promise<ActiveServer[
ports.map(async (port) => {
const config = await probePort(port);
if (!config) return null;
const pid = await getProcessOnPort(port);
const pid =
Number.isInteger(config.pid) && Number(config.pid) > 0
? String(config.pid)
: await getProcessOnPort(port);
return {
port,
projectName: config.projectName,
@@ -280,6 +280,7 @@ describe("studioRenderTelemetry", () => {
progress: 25,
currentStage: "Starting frame capture",
createdAt: new Date(),
warnings: [],
errorDetails: {
message: "Navigation timeout of 60000 ms exceeded",
elapsedMs: 60_001,
+6 -1
View File
@@ -24,6 +24,7 @@ import {
createStudioApi,
createProjectSignature,
createBackgroundRemovalJob,
consumeFileWriteReceipt,
getMimeType,
type StudioApiAdapter,
type ResolvedProject,
@@ -600,6 +601,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
const serverBuildSignature = await loadPreviewServerBuildSignature();
return c.json({
isHyperframes: true,
pid: process.pid,
projectName: projectId,
projectDir: projectDir,
serverBuildSignature,
@@ -636,7 +638,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
app.get("/api/events", (c) => {
return streamSSE(c, async (stream) => {
const listener = (path: string) => {
stream.writeSSE({ event: "file-change", data: JSON.stringify({ path }) }).catch(() => {});
const receipt = consumeFileWriteReceipt(resolve(projectDir, path));
stream
.writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) })
.catch(() => {});
};
watcher.addListener(listener);
while (true) {
+3
View File
@@ -27,6 +27,8 @@ export interface HyperframesConfig {
lastUpdateCheck?: string;
/** Latest version found on npm */
latestVersion?: string;
/** Throttle for the non-TTY stale-project-pin notice (ms epoch). */
lastStalePinNoticeAt?: number;
/**
* Auto-update marker. Set when a background install is spawned so a
* subsequent run can skip re-triggering it. Cleared once
@@ -122,6 +124,7 @@ export function readConfig(): HyperframesConfig {
lastFeedbackPromptAt: parsed.lastFeedbackPromptAt ?? DEFAULT_CONFIG.lastFeedbackPromptAt,
lastUpdateCheck: parsed.lastUpdateCheck,
latestVersion: parsed.latestVersion,
lastStalePinNoticeAt: parsed.lastStalePinNoticeAt,
pendingUpdate: parsed.pendingUpdate,
completedUpdate: parsed.completedUpdate,
lastSkillsCheck: parsed.lastSkillsCheck,
+68
View File
@@ -252,6 +252,32 @@ describe("render telemetry events", () => {
);
});
it("carries the failing dB, frame index, and threshold on render_error for a psnr fallback that failed hard afterward", () => {
trackRenderError({
fps: 30,
quality: "standard",
docker: false,
errorMessage: "worker crashed after a psnr fallback",
captureDeParallelRouter: "reverted",
captureDeSelfVerifyFallback: true,
captureDeFallbackReason: "psnr",
captureDeFallbackFailedDb: 28.4,
captureDeFallbackFrameIndex: 649,
captureDeFallbackThresholdDb: 32,
});
expect(trackEvent).toHaveBeenCalledWith(
"render_error",
expect.objectContaining({
de_fallback_reason: "psnr",
de_fallback_failed_db: 28.4,
de_fallback_frame_index: 649,
de_fallback_threshold_db: 32,
}),
undefined,
);
});
it("prefers the explicit perfSummary-sourced de_worker_inversion over the capture-observability fallback on render_complete", () => {
trackRenderComplete({
durationMs: 1000,
@@ -272,6 +298,32 @@ describe("render telemetry events", () => {
);
});
it("carries the perfSummary-sourced failing dB, frame index, and threshold on render_complete", () => {
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "standard",
docker: false,
gpu: false,
deParallelRouter: "reverted",
deFallbackReason: "psnr",
deFallbackFailedDb: 28.4,
deFallbackFrameIndex: 649,
deFallbackThresholdDb: 32,
});
expect(trackEvent).toHaveBeenCalledWith(
"render_complete",
expect.objectContaining({
de_fallback_reason: "psnr",
de_fallback_failed_db: 28.4,
de_fallback_frame_index: 649,
de_fallback_threshold_db: 32,
}),
undefined,
);
});
it("emits render_preflight_rejected with the low-cardinality issue kind", () => {
trackRenderPreflightRejected({ kind: "aspect-mismatch" });
expect(trackEvent).toHaveBeenCalledWith("render_preflight_rejected", {
@@ -400,6 +452,7 @@ describe("trackRenderFeedback", () => {
const [, props] = trackEvent.mock.calls[0] as [string, Record<string, unknown>];
expect(props).not.toHaveProperty("render_duration_ms");
expect(props.$survey_response).toBe(4);
expect(props.rating_scale).toBe(10);
});
it("includes render_duration_ms when a real duration is supplied", () => {
@@ -431,6 +484,21 @@ describe("trackCliError", () => {
expect(props.error_message).toContain("[path]");
expect(props.stack_trace).not.toContain("/Users/alice");
});
it("forwards the figma endpoint label when supplied", () => {
trackCliError({
error_name: "RATE_LIMITED",
error_message: "figma rate limit hit (429)",
command: "figma asset",
kind: "command_error",
endpoint: "images",
});
expect(trackEvent).toHaveBeenCalledWith(
"cli_error",
expect.objectContaining({ endpoint: "images" }),
);
});
});
describe("trackCommandFailure", () => {
+18
View File
@@ -1,5 +1,6 @@
import { redactTelemetryString, type OutputResolutionIssueKind } from "@hyperframes/core";
import type { SubTimelineWaitOutcome } from "@hyperframes/engine";
import { FEEDBACK_RATING_SCALE } from "../utils/feedbackRating.js";
import { flush, trackEvent } from "./client.js";
import { readConfig } from "./config.js";
@@ -53,6 +54,9 @@ export interface RenderObservabilityTelemetryPayload {
captureDePreRouterWorkers?: number;
captureDeSelfVerifyFallback?: boolean;
captureDeFallbackReason?: string;
captureDeFallbackFailedDb?: number;
captureDeFallbackFrameIndex?: number;
captureDeFallbackThresholdDb?: number;
/** Non-DE parallel-streaming router outcome ("screenshot" | "beginframe" —
* routed; "eligible_off" — would route but the kill switch is off). */
captureParallelStream?: string;
@@ -107,6 +111,9 @@ function renderObservabilityEventProperties(props: RenderObservabilityTelemetryP
de_pre_router_workers: props.captureDePreRouterWorkers,
de_self_verify_fallback: props.captureDeSelfVerifyFallback,
de_fallback_reason: props.captureDeFallbackReason,
de_fallback_failed_db: props.captureDeFallbackFailedDb,
de_fallback_frame_index: props.captureDeFallbackFrameIndex,
de_fallback_threshold_db: props.captureDeFallbackThresholdDb,
capture_parallel_stream: props.captureParallelStream,
observability_extract_video_count: props.observabilityExtractVideoCount,
observability_extracted_video_count: props.observabilityExtractedVideoCount,
@@ -172,6 +179,9 @@ export function trackRenderComplete(
deVerifyInitMs?: number;
deSelfVerifyFallback?: boolean;
deFallbackReason?: string;
deFallbackFailedDb?: number;
deFallbackFrameIndex?: number;
deFallbackThresholdDb?: number;
deBlankSuspects?: number;
deBlankDeterministicAccepts?: number;
deBlankRecaptures?: number;
@@ -259,6 +269,9 @@ export function trackRenderComplete(
de_verify_init_ms: props.deVerifyInitMs,
de_self_verify_fallback: props.deSelfVerifyFallback,
de_fallback_reason: props.deFallbackReason,
de_fallback_failed_db: props.deFallbackFailedDb,
de_fallback_frame_index: props.deFallbackFrameIndex,
de_fallback_threshold_db: props.deFallbackThresholdDb,
de_blank_suspects: props.deBlankSuspects,
de_blank_deterministic_accepts: props.deBlankDeterministicAccepts,
de_blank_recaptures: props.deBlankRecaptures,
@@ -490,6 +503,9 @@ export function trackCliError(props: {
stack_trace?: string;
command?: string;
kind: "uncaught_exception" | "unhandled_rejection" | "command_error";
/** Low-cardinality figma REST call label (e.g. "images", "files_nodes") —
* which endpoint failed, for FigmaClientError-backed failures only. */
endpoint?: string;
}): void {
trackEvent("cli_error", {
error_name: props.error_name,
@@ -502,6 +518,7 @@ export function trackCliError(props: {
: undefined,
command: props.command,
kind: props.kind,
endpoint: props.endpoint,
});
}
@@ -596,6 +613,7 @@ export function trackRenderFeedback(props: {
trackEvent("survey sent", {
$survey_id: "render_satisfaction",
$survey_response: props.rating,
rating_scale: FEEDBACK_RATING_SCALE,
...(props.comment ? { $survey_response_2: props.comment } : {}),
...(props.renderDurationMs !== undefined ? { render_duration_ms: props.renderDurationMs } : {}),
...(props.doctorSummary ? { doctor_summary: props.doctorSummary } : {}),
+5 -4
View File
@@ -4,6 +4,7 @@ import { shouldTrack } from "./client.js";
import { trackRenderFeedback } from "./events.js";
import { detectAgentRuntime } from "./agent_runtime.js";
import { c } from "../ui/colors.js";
import { parseFeedbackRating } from "../utils/feedbackRating.js";
const DEFAULT_FEEDBACK_INTERVAL = 15;
@@ -50,7 +51,7 @@ export async function maybePromptRenderFeedback(opts: {
console.log(
c.dim(" [hyperframes] ") +
c.dim("Agent feedback: ") +
c.accent('hyperframes feedback --rating <1-5> --comment "..."'),
c.accent('hyperframes feedback --rating <0-10> --comment "..."'),
);
return;
}
@@ -66,11 +67,11 @@ export async function maybePromptRenderFeedback(opts: {
writeConfig(config);
const answer = await askQuestion(
` ${c.dim("How was this render?")} ${c.accent("[1=poor 5=great, enter to skip]")} `,
` ${c.dim("How likely are you to recommend HyperFrames?")} ${c.accent("[0=not likely 10=extremely likely, enter to skip]")} `,
);
const rating = parseInt(answer.trim(), 10);
if (rating >= 1 && rating <= 5) {
const rating = parseFeedbackRating(answer);
if (rating !== null) {
// Ask for optional text feedback
const details = await askQuestion(` ${c.dim("Any details?")} ${c.accent("(enter to skip)")} `);
const trimmedDetails = details.trim();
@@ -81,6 +81,30 @@ describe("renderObservabilityTelemetryPayload — DE inversion/router cohort (fa
const payload = renderObservabilityTelemetryPayload(makeSummary({}));
expect(payload.captureDeFallbackReason).toBeUndefined();
});
it("carries the failing dB, frame index, and threshold for a psnr fallback, still visible on a hard failure", () => {
const payload = renderObservabilityTelemetryPayload(
makeSummary({
deParallelRouter: "routed",
deFallbackReason: "psnr",
deFallbackFailedDb: 28.4,
deFallbackFrameIndex: 649,
deFallbackThresholdDb: 32,
}),
);
expect(payload.captureDeFallbackFailedDb).toBe(28.4);
expect(payload.captureDeFallbackFrameIndex).toBe(649);
expect(payload.captureDeFallbackThresholdDb).toBe(32);
});
it("leaves failedDb/thresholdDb undefined for a blank/oom/capture_error fallback (no PSNR score exists)", () => {
const payload = renderObservabilityTelemetryPayload(
makeSummary({ deFallbackReason: "oom", deFallbackFrameIndex: undefined }),
);
expect(payload.captureDeFallbackFailedDb).toBeUndefined();
expect(payload.captureDeFallbackFrameIndex).toBeUndefined();
expect(payload.captureDeFallbackThresholdDb).toBeUndefined();
});
});
describe("renderObservabilityTelemetryPayload — non-DE parallel-stream router", () => {
@@ -46,6 +46,9 @@ export function renderObservabilityTelemetryPayload(
captureDePreRouterWorkers: capture.dePreRouterWorkers,
captureDeSelfVerifyFallback: capture.deSelfVerifyFallback,
captureDeFallbackReason: capture.deFallbackReason,
captureDeFallbackFailedDb: capture.deFallbackFailedDb,
captureDeFallbackFrameIndex: capture.deFallbackFrameIndex,
captureDeFallbackThresholdDb: capture.deFallbackThresholdDb,
captureParallelStream: capture.captureParallelStream,
observabilityExtractVideoCount: extraction?.videoCount,
observabilityExtractedVideoCount: extraction?.extractedVideoCount,
+5 -4
View File
@@ -4,10 +4,9 @@
**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
- `/product-launch-video` — a **product** URL or brief / script → 60-90s product launch / SaaS / promo video.
- `/website-to-video` — a **general** website / URL → a video _of_ the site (tour / showcase / social clip from captured visuals); a product **launch / promo** is `/product-launch-video`.
- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals.
- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer.
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched.
- `/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. (Plain captions/subtitles → `/embedded-captions`.)
@@ -15,7 +14,7 @@
- `/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 (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition); the original hyperframes authoring flow, any length.
- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length.
**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
@@ -45,6 +44,8 @@ npx hyperframes docs <topic> # reference docs in terminal
> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground
> command — it will time out and the server will die, breaking the browser preview.
> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project --check` (shows the delta), then `npx hyperframes@latest upgrade --project` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself.
## Documentation
**For quick reference**, use the local CLI docs command (no network required):
+5 -4
View File
@@ -4,10 +4,9 @@
**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
- `/product-launch-video` — a **product** URL or brief / script → 60-90s product launch / SaaS / promo video.
- `/website-to-video` — a **general** website / URL → a video _of_ the site (tour / showcase / social clip from captured visuals); a product **launch / promo** is `/product-launch-video`.
- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals.
- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer.
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched.
- `/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. (Plain captions/subtitles → `/embedded-captions`.)
@@ -15,7 +14,7 @@
- `/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 (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition); the original hyperframes authoring flow, any length.
- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length.
**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
@@ -45,6 +44,8 @@ npx hyperframes docs <topic> # reference docs in terminal
> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground
> command — it will time out and the server will die, breaking the browser preview.
> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project --check` (shows the delta), then `npx hyperframes@latest upgrade --project` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself.
## Documentation
**For quick reference**, use the local CLI docs command (no network required):
+33
View File
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it } from "vitest";
import { renderProgress } from "./progress.js";
const originalWrite = process.stdout.write.bind(process.stdout);
const originalIsTTY = process.stdout.isTTY;
afterEach(() => {
process.stdout.write = originalWrite;
Object.defineProperty(process.stdout, "isTTY", {
value: originalIsTTY,
configurable: true,
});
});
describe("renderProgress", () => {
it("emits line-delimited updates when stdout is not a TTY", () => {
let output = "";
Object.defineProperty(process.stdout, "isTTY", {
value: false,
configurable: true,
});
process.stdout.write = ((chunk: string | Uint8Array) => {
output += String(chunk);
return true;
}) as typeof process.stdout.write;
renderProgress(42, "Capturing frames");
expect(output).toMatch(/Capturing frames\n$/);
expect(output).not.toContain("\r");
});
});
+2
View File
@@ -12,6 +12,8 @@ export function renderProgress(percent: number, stage: string, row?: number): vo
if (row !== undefined && stdout.isTTY) {
stdout.write(`\x1b[${row};1H\x1b[2K${line}`);
} else if (!stdout.isTTY) {
stdout.write(`${line}\n`);
} else {
stdout.write(`\r\x1b[2K${line}`);
}
@@ -1,9 +1,19 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const producerMocks = vi.hoisted(() => ({
loadProducer: vi.fn(async () => ({
injectDeterministicFontFaces: async (html: string) => `${html}<!--bundled-producer-->`,
})),
}));
vi.mock("@hyperframes/core/compiler", () => ({
bundleToSingleHtml: vi.fn(async () => "<html><body>bundled</body></html>"),
}));
vi.mock("./producer.js", () => ({
loadProducer: producerMocks.loadProducer,
}));
import {
__resetFontLocalizationWarningsForTests,
bundleWithLocalizedFonts,
@@ -31,6 +41,12 @@ describe("bundleWithLocalizedFonts (call-site integration)", () => {
});
describe("localizeWithProducer", () => {
it("loads the injector through the producer module bundled with the CLI", async () => {
const out = await localizeWithProducer("<html></html>");
expect(producerMocks.loadProducer).toHaveBeenCalledOnce();
expect(out).toBe("<html></html><!--bundled-producer-->");
});
it("embeds fonts when the injector is available", async () => {
const inject = vi.fn(async (html: string) => `${html}<!--fonts-->`);
const warn = vi.fn();
@@ -29,14 +29,11 @@ export async function bundleWithLocalizedFonts(
type FontInjector = (html: string) => Promise<string>;
/**
* Load the render pipeline's `injectDeterministicFontFaces`, resolving
* `@hyperframes/producer` at RUNTIME only. The specifier is kept out of the
* bundler's/test-runner's static module graph (`@vite-ignore` + a variable
* specifier) on purpose: the CLI test job builds with `--filter
* '!@hyperframes/producer'`, so a static `import("@hyperframes/producer")`
* would fail Vitest's transform-time resolution. At runtime — the built CLI or
* an installed package — producer is a real dependency and resolves via
* node_modules.
* Load the render pipeline's `injectDeterministicFontFaces` through the CLI's
* canonical producer loader. That loader uses a literal dynamic import, which
* lets tsup see and inline producer into the published CLI bundle. A variable
* bare-package import is invisible to tsup and silently fails after `npm
* install`, because producer is intentionally only a workspace devDependency.
*
* Returns `null` (not a throw) when the module simply isn't available in this
* environment, so the caller can treat "producer absent" — a benign, expected
@@ -44,8 +41,8 @@ type FontInjector = (html: string) => Promise<string>;
*/
async function loadFontInjector(): Promise<FontInjector | null> {
try {
const producerSpecifier = "@hyperframes/producer";
const mod = (await import(/* @vite-ignore */ producerSpecifier)) as {
const { loadProducer } = await import("./producer.js");
const mod = (await loadProducer()) as {
injectDeterministicFontFaces?: FontInjector;
};
return mod.injectDeterministicFontFaces ?? null;
+5 -1
View File
@@ -510,7 +510,11 @@ async function collectContrast(
// This screenshot is the one contrast math is sampled from below — it must
// stay untouched by the annotation overlay (finishContrast reads real
// painted pixels), so annotation only ever happens on a SECOND shot.
const measurementShot = await page.screenshot({ encoding: "base64", type: "png" });
const measurementShot = await page.screenshot({
encoding: "base64",
type: "png",
omitBackground: true,
});
if (typeof measurementShot !== "string") throw new Error("Contrast screenshot was not base64");
const raw = await finishContrast(
page,
@@ -62,6 +62,7 @@ export function injectRuntime(html: string): string {
const ASSET_CONTENT_TYPES: Record<string, string> = {
js: "application/javascript",
mjs: "application/javascript",
css: "text/css",
json: "application/json",
png: "image/png",
@@ -172,6 +172,7 @@ describe("buildDockerRunArgs", () => {
videoFrameFormat: "png",
quiet: true,
debug: true,
bestEffort: false,
entryFile: "compositions/intro.html",
experimentalFastCapture: true,
},
@@ -191,6 +192,7 @@ describe("buildDockerRunArgs", () => {
expect(args).toContain("png");
expect(args).toContain("--quiet");
expect(args).toContain("--debug");
expect(args).toContain("--no-best-effort");
expect(args).toContain("--gpu");
expect(args).toContain("--no-browser-gpu");
expect(args).toContain("--hdr");
@@ -199,6 +201,21 @@ describe("buildDockerRunArgs", () => {
expect(args).toContain("--experimental-fast-capture");
});
it("forwards only an explicit strict-readiness opt-in", () => {
const compatible = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, bestEffort: true },
});
expect(compatible).not.toContain("--best-effort");
expect(compatible).not.toContain("--no-best-effort");
const strict = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, bestEffort: false },
});
expect(strict).toContain("--no-best-effort");
});
it("forwards --experimental-fast-capture only when enabled", () => {
const on = buildDockerRunArgs({
...FIXED_INPUT,
+4
View File
@@ -51,6 +51,7 @@ export interface DockerRenderOptions {
videoFrameFormat?: "auto" | "jpg" | "png";
quiet: boolean;
debug?: boolean;
bestEffort?: boolean;
variables?: Record<string, unknown>;
entryFile?: string;
/** Output resolution preset (e.g. "landscape-4k"). Forwarded as `--resolution`. */
@@ -136,6 +137,9 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
: []),
...(options.quiet ? ["--quiet"] : []),
...(options.debug ? ["--debug"] : []),
// The in-container CLI is best-effort by default. Only forward the
// explicit strict opt-in so Docker and local renders cannot drift.
...(options.bestEffort === false ? ["--no-best-effort"] : []),
...(options.gpu ? ["--gpu"] : []),
...(options.browserGpu ? [] : ["--no-browser-gpu"]),
...(options.hdrMode === "force-hdr" ? ["--hdr"] : []),
+2 -2
View File
@@ -27,7 +27,7 @@ describe("buildIssueUrl", () => {
const url = buildIssueUrl(base);
expect(decoded(url, "title")).toBe("[feedback] GSAP timeline froze on seek");
const body = decoded(url, "body");
expect(body).toContain("2/5");
expect(body).toContain("2/10");
expect(body).toContain("https://hyperframes.dev/p/abc123");
expect(body).toContain("os=darwin/arm64");
expect(body).toContain("cli=1.2.3");
@@ -35,7 +35,7 @@ describe("buildIssueUrl", () => {
it("falls back to a generic title when there is no comment", () => {
const url = buildIssueUrl({ ...base, comment: undefined });
expect(decoded(url, "title")).toBe("Render feedback (rating 2/5)");
expect(decoded(url, "title")).toBe("Render feedback (rating 2/10)");
});
it("truncates an overlong comment in the body", () => {
+4 -2
View File
@@ -1,3 +1,5 @@
import { FEEDBACK_RATING_SCALE } from "./feedbackRating.js";
// Reading package.json at runtime from the single-file bundled CLI is awkward,
// so we keep the canonical repo as a constant. It must match the `repository.url`
// in packages/cli/package.json.
@@ -33,7 +35,7 @@ function truncate(value: string, max: number): string {
function buildIssueTitle(rating: number, comment?: string): string {
const firstLine = comment?.split("\n")[0]?.trim();
if (!firstLine) return `Render feedback (rating ${rating}/5)`;
if (!firstLine) return `Render feedback (rating ${rating}/${FEEDBACK_RATING_SCALE})`;
return `[feedback] ${truncate(firstLine, TITLE_MAX)}`;
}
@@ -44,7 +46,7 @@ function buildIssueBody(input: IssueInput): string {
: "_Publishing the repro failed, no public link available._";
return [
`**Rating:** ${input.rating}/5`,
`**Rating:** ${input.rating}/${FEEDBACK_RATING_SCALE}`,
"",
"## Comment",
comment ? truncate(comment, COMMENT_MAX) : "_No comment provided._",
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { FEEDBACK_RATING_SCALE, parseFeedbackRating } from "./feedbackRating.js";
describe("parseFeedbackRating", () => {
it.each(["0", "10"])("accepts the NPS boundary %s", (raw) => {
expect(parseFeedbackRating(raw)).toBe(Number(raw));
});
it.each(["-1", "11", "4.5", "10x", ""])("rejects invalid rating %j", (raw) => {
expect(parseFeedbackRating(raw)).toBeNull();
});
it("declares the serialized rating scale", () => {
expect(FEEDBACK_RATING_SCALE).toBe(10);
});
});
+8
View File
@@ -0,0 +1,8 @@
export const FEEDBACK_RATING_SCALE = 10;
export function parseFeedbackRating(raw: string): number | null {
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) return null;
const rating = Number(trimmed);
return Number.isInteger(rating) && rating >= 0 && rating <= FEEDBACK_RATING_SCALE ? rating : null;
}

Some files were not shown because too many files have changed in this diff Show More