Merge branch 'main' into fix/electron-nested-iframe-raf-tick

Resolves conflicts in packages/player/src/hyperframes-player.ts
introduced by main's LOC-split refactor (PR #750), which extracted
DirectTimelineClock + ParentMediaManager into separate modules.

The PR's parent-tick-clock additions are re-applied on top of the
refactored class structure:

- New `_parentTickRaf` field
- New `_startParentTickClock` / `_stopParentTickClock` private methods
  (placed alongside `_sendControl`, since the tick uses postMessage)
- `_stopParentTickClock()` call added to `disconnectedCallback`,
  `pause`, `seek`, and `_onIframeLoad` (alongside the existing
  `this._directTimelineClock.stop()` calls there)
- `play()` updated:
  - `this._paused = false` moved BEFORE `_startParentTickClock` so the
    RAF tick's `_paused` check doesn't self-terminate on first callback
  - `_startParentTickClock()` only fires on the runtime-bridge path
    (`!directTimelineStarted`) AND when ready + no adapter, matching
    the second PR commit's review fix

No semantic change vs. the pre-refactor PR head — this commit only
reconciles the surface differences from #750.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
tai (bot)
2026-05-13 20:07:53 +00:00
co-authored by Claude Opus 4.7
599 changed files with 291341 additions and 10759 deletions
+1
View File
@@ -0,0 +1 @@
packages/studio/src/player/hooks/useTimelinePlayer.ts
+5 -1
View File
@@ -1,5 +1,8 @@
name: Catalog Previews
permissions:
contents: read
on:
pull_request:
branches: [main]
@@ -39,9 +42,10 @@ jobs:
chrome-version: stable
- name: Render changed block/component previews
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
# Find which blocks/components changed in this PR
BASE_SHA=${{ github.event.pull_request.base.sha }}
CHANGED_ITEMS=$(git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD -- registry/blocks/ registry/components/ \
| grep -E '^registry/(blocks|components)/' \
| sed 's|^registry/[^/]*/\([^/]*\)/.*|\1|' \
+26 -3
View File
@@ -1,5 +1,9 @@
name: CI
permissions:
contents: read
pull-requests: read
on:
pull_request:
# `edited` is required so the workflow re-fires when a PR's base ref is
@@ -224,11 +228,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
lfs: true
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: Install FFmpeg
@@ -314,6 +318,25 @@ jobs:
test -s /tmp/hf-cli-outside/renders/outside.mp4
filesize:
name: File size check
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Check file sizes (max 500 lines)
run: |
EXIT=0
while IFS= read -r f; do
if grep -qxF "$f" .filesize-allowlist 2>/dev/null; then continue; fi
lines=$(wc -l < "$f")
if [ "$lines" -gt 500 ]; then
echo "::error file=$f::$f has $lines lines (max 500)"
EXIT=1
fi
done < <(find packages/studio -path '*/node_modules' -prune -o \( -name '*.ts' -o -name '*.tsx' \) -print | grep -vE '\.(test|spec)\.(ts|tsx)$|\.generated\.')
exit $EXIT
semantic-pr-title:
name: Semantic PR title
if: github.event_name == 'pull_request'
+3
View File
@@ -1,5 +1,8 @@
name: Docs
permissions:
contents: read
on:
pull_request:
branches: [main]
+3
View File
@@ -1,5 +1,8 @@
name: Player perf
permissions:
contents: read
on:
pull_request:
push:
+4 -1
View File
@@ -1,5 +1,8 @@
name: preview-regression
permissions:
contents: read
on:
pull_request:
push:
@@ -67,7 +70,7 @@ jobs:
run: bun run --cwd packages/producer parity:fixtures:ci
- name: Install ffmpeg
uses: FedericoCarboni/setup-ffmpeg@36c6454b5a2348e7794ba2d82a21506605921e3d # v3
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends ffmpeg
- name: Set up Chrome
id: setup-chrome
+21 -12
View File
@@ -1,5 +1,7 @@
name: Publish to npm
permissions: {}
on:
push:
tags:
@@ -42,14 +44,18 @@ jobs:
- name: Resolve version
id: version
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ inputs.version }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
if [ "${{ github.event_name }}" = "push" ]; then
if [ "$EVENT_NAME" = "push" ]; then
VERSION="${GITHUB_REF_NAME#v}"
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then
VERSION="${INPUT_VERSION}"
VERSION="${VERSION#v}"
else
BRANCH="${{ github.event.pull_request.head.ref }}"
BRANCH="${PR_HEAD_REF}"
VERSION="${BRANCH#release/v}"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
@@ -75,9 +81,11 @@ jobs:
- name: Create release tag
if: github.event_name == 'pull_request'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
git tag "v${{ steps.version.outputs.version }}"
git push origin "v${{ steps.version.outputs.version }}"
git tag "v$VERSION"
git push origin "v$VERSION"
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -92,9 +100,9 @@ jobs:
- name: Publish packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
DIST_TAG: ${{ steps.version.outputs.dist_tag }}
run: |
VERSION="${{ steps.version.outputs.version }}"
DIST_TAG="${{ steps.version.outputs.dist_tag }}"
FAILED=0
publish_pkg() {
@@ -153,14 +161,15 @@ jobs:
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
PRERELEASE: ${{ steps.version.outputs.prerelease }}
REPO: ${{ github.repository }}
run: |
VERSION="${{ steps.version.outputs.version }}"
PRERELEASE="${{ steps.version.outputs.prerelease }}"
# Skip if release already exists (idempotent re-runs)
if gh release view "v${VERSION}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
if gh release view "v${VERSION}" --repo "$REPO" >/dev/null 2>&1; then
echo "Release v${VERSION} already exists — skipping"
else
FLAGS=(--repo "${{ github.repository }}" --title "v${VERSION}" --generate-notes)
FLAGS=(--repo "$REPO" --title "v${VERSION}" --generate-notes)
if [ "$PRERELEASE" = "true" ]; then
FLAGS+=(--prerelease)
fi
+3
View File
@@ -1,5 +1,8 @@
name: regression
permissions:
contents: read
on:
pull_request:
push:
+31 -2
View File
@@ -1,5 +1,8 @@
name: Windows render verification
permissions:
contents: read
# Manually triggered smoke test that renders a HyperFrames composition on a
# real Windows runner. Proves the PR #336 `where ffmpeg` fix actually works
# end-to-end: FFmpeg is discovered natively on Windows, Chrome is installed
@@ -62,6 +65,10 @@ jobs:
timeout-minutes: 30
steps:
- name: Disable Windows Defender real-time monitoring
shell: pwsh
run: Set-MpPreference -DisableRealtimeMonitoring $true
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
@@ -143,7 +150,16 @@ jobs:
- name: Install dependencies
shell: pwsh
run: bun install --frozen-lockfile
# Force the pre-1.3 "hoisted" linker on Windows. Bun 1.3.x's default
# "isolated" linker creates nested workspace junctions that don't
# materialize reliably on Windows GHA runners — manifests as
# `Cannot find package 'esbuild'` walking up from
# `packages/producer/build.mjs` and `EPERM stat` on
# `packages/producer/node_modules/@fontsource/*` from generators.
# See oven-sh/bun#23615, #18354, #10146. Linux CI keeps isolated;
# the lockfile is linker-agnostic so this flag is safe with
# --frozen-lockfile.
run: bun install --frozen-lockfile --linker=hoisted
- name: Build all packages
shell: pwsh
@@ -334,6 +350,10 @@ jobs:
timeout-minutes: 20
steps:
- name: Disable Windows Defender real-time monitoring
shell: pwsh
run: Set-MpPreference -DisableRealtimeMonitoring $true
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
@@ -360,7 +380,16 @@ jobs:
- name: Install dependencies
shell: pwsh
run: bun install --frozen-lockfile
# Force the pre-1.3 "hoisted" linker on Windows. Bun 1.3.x's default
# "isolated" linker creates nested workspace junctions that don't
# materialize reliably on Windows GHA runners — manifests as
# `Cannot find package 'esbuild'` walking up from
# `packages/producer/build.mjs` and `EPERM stat` on
# `packages/producer/node_modules/@fontsource/*` from generators.
# See oven-sh/bun#23615, #18354, #10146. Linux CI keeps isolated;
# the lockfile is linker-agnostic so this flag is safe with
# --frozen-lockfile.
run: bun install --frozen-lockfile --linker=hoisted
- name: Build
shell: pwsh
+5 -1
View File
@@ -62,7 +62,8 @@ tmp/
packages/core/src/generated/
packages/producer/src/services/fontData.generated.ts
# Test artifacts
# Local proof / test artifacts
qa-artifacts/
my-video/
examples/
packages/studio/data/
@@ -99,3 +100,6 @@ ab-test/
compositions/
video-6-2-patched/
claude-design-hyperframes-video/
.claude/worktrees/
.claude/
docs/superpowers/
+482 -840
View File
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
---
title: Variables
description: "Parameterize compositions so the same source can render different content."
---
Variables let you declare named, typed slots in a composition and fill them at render time — from a parent composition, from the CLI, or from an API call. A card composition that takes `title` and `color` can be embedded a hundred times with a hundred different values without duplicating any HTML.
## Declaring Variables
Add `data-composition-variables` to the `<html>` root of any composition. Its value is a JSON array of variable declarations — one object per variable:
```html compositions/card.html
<html data-composition-variables='[
{"id":"title", "type":"string", "label":"Title", "default":"Hello"},
{"id":"color", "type":"color", "label":"Color", "default":"#111827"},
{"id":"price", "type":"number", "label":"Price", "default":0, "unit":"$"},
{"id":"featured","type":"boolean","label":"Featured","default":false},
{"id":"plan", "type":"enum", "label":"Plan", "default":"pro",
"options":[{"value":"pro","label":"Pro"},{"value":"enterprise","label":"Enterprise"}]}
]'>
```
Every declaration requires four fields: `id`, `type`, `label`, and `default`. `id` must be unique within the composition.
## Variable Types
| Type | `default` value | Extra options |
|------|----------------|---------------|
| `string` | `"some text"` | `placeholder?: string`, `maxLength?: number` |
| `number` | `0` | `min?: number`, `max?: number`, `step?: number`, `unit?: string` |
| `color` | `"#rrggbb"` | — |
| `boolean` | `true` / `false` | — |
| `enum` | one of the option values | `options: [{value: string, label: string}]` |
The Studio editing UI uses `label`, `type`, and the type-specific options to render the right input widget for each variable.
## Reading Variables at Runtime
Inside any composition script, call `window.__hyperframes.getVariables()` to get the resolved variable values. The return type is `Partial<Record<string, unknown>>` — use destructuring with defaults matching the declared `default` values:
```html compositions/card.html
<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Untitled"},
{"id":"color","type":"color","label":"Color","default":"#111827"}
]'>
<body>
<div data-composition-id="card" data-width="1920" data-height="1080">
<h1 class="card-title"></h1>
<style>
[data-composition-id="card"] { --card-color: #111827; }
[data-composition-id="card"] .card-title { color: var(--card-color); }
</style>
<script>
const { title = "Untitled", color = "#111827" } = __hyperframes.getVariables();
const root = document.querySelector('[data-composition-id="card"]');
root.querySelector(".card-title").textContent = title;
root.style.setProperty("--card-color", color);
</script>
</div>
</body>
</html>
```
`__hyperframes.getVariables()` is a shorthand for `window.__hyperframes.getVariables()` and works in both top-level and sub-composition scripts. The runtime automatically scopes sub-compositions so each instance sees its own resolved values.
## Per-instance Overrides (Sub-compositions)
When embedding a composition inside another, use `data-variable-values` on the host element to pass a JSON object of override values for that particular instance:
```html index.html
<div
data-composition-id="card-pro"
data-composition-src="compositions/card.html"
data-start="0"
data-track-index="1"
data-variable-values='{"title":"Pro","color":"#ff4d4f"}'
></div>
<div
data-composition-id="card-enterprise"
data-composition-src="compositions/card.html"
data-start="card-pro"
data-track-index="1"
data-variable-values='{"title":"Enterprise","color":"#22c55e"}'
></div>
```
Both host elements point to the same `card.html` source, but each instance receives different values. The runtime merges the host's `data-variable-values` over the sub-comp's declared defaults on a per-instance basis — the same sub-composition can run with completely different content simultaneously.
## CLI Overrides (Top-level Renders)
Pass variable values at render time with `--variables` or `--variables-file`. These override the declared defaults for the top-level composition:
```bash Terminal
# Inline JSON
npx hyperframes render --variables '{"title":"Q4 Report","color":"#1d4ed8"}' --output q4.mp4
# JSON file
npx hyperframes render --variables-file ./vars.json --output out.mp4
# Fail on undeclared or mistyped variables
npx hyperframes render --variables '{"title":"Q4 Report"}' --strict-variables --output out.mp4
```
`--strict-variables` turns variable warnings into errors. Any variable in `--variables` that is not declared in `data-composition-variables`, or whose value does not match the declared type, causes the render to exit non-zero. Useful in CI pipelines where an undeclared variable key likely indicates a typo or a schema mismatch.
<Note>
CLI overrides apply only to the top-level composition. Sub-composition variables are controlled by `data-variable-values` on each host element.
</Note>
## Layering and Precedence
Variable values are resolved by merging three sources, lowest to highest precedence:
| Source | Precedence | Where declared |
|--------|-----------|---------------|
| Declared defaults | Lowest | `data-composition-variables` on `<html>` |
| Per-instance host overrides | Middle | `data-variable-values` on the sub-comp host element |
| CLI `--variables` flag | Highest | `hyperframes render --variables '{...}'` |
A missing key at any layer falls through to the next lower layer. If no layer provides a value, the declared `default` is used.
## Validation
The linter checks variable declarations statically:
```bash Terminal
npx hyperframes lint
```
It catches malformed JSON, missing required fields (`id`, `type`, `label`, `default`), and type mismatches between `type` and the `default` value. Fix lint errors before rendering — they indicate the runtime will be unable to resolve variables correctly.
At render time, the CLI validates `--variables` against the schema and reports issues as warnings (or errors with `--strict-variables`):
- **undeclared** — a key in `--variables` has no matching `id` in `data-composition-variables`
- **type-mismatch** — the value's JavaScript type does not match the declared `type` (e.g. a string where a number is expected)
- **enum-out-of-range** — an enum value is not in the declared `options` list
## Inspecting Variables Programmatically
If you are building tooling on top of `@hyperframes/core`, the variable declarations are readable without rendering:
```typescript
import { extractCompositionMetadata } from "@hyperframes/core";
import { readFileSync } from "node:fs";
const html = readFileSync("compositions/card.html", "utf8");
const { variables } = extractCompositionMetadata(html);
// variables is CompositionVariable[]
```
This is the same API the Studio editing UI uses to build the variables panel for each composition.
## Next Steps
<CardGroup cols={2}>
<Card title="Data Attributes" icon="code" href="/concepts/data-attributes">
Full reference for data-composition-variables and data-variable-values attributes
</Card>
<Card title="Compositions" icon="layer-group" href="/concepts/compositions">
How nested compositions use variables for reuse
</Card>
<Card title="Rendering" icon="play" href="/guides/rendering">
CLI flags for passing variables at render time
</Card>
<Card title="CLI Reference" icon="terminal" href="/packages/cli">
All CLI commands and flags
</Card>
</CardGroup>
+8
View File
@@ -53,6 +53,14 @@ bun run build # Build all packages
bun run --filter '*' typecheck # Type-check all packages
```
### Studio Editing Work
If you are changing Studio's visual editing surface, read
[Studio Manual DOM Editing](/contributing/studio-manual-dom-editing) before
editing code. The inspector intentionally exposes only interactions it can
persist safely back to HTML, so changes should preserve the capability gates,
source patching model, and documented limitations.
### Running Tests
<CodeGroup>
@@ -0,0 +1,315 @@
---
title: Studio Manual DOM Editing
description: What the Studio manual DOM editing inspector ships today, including capabilities, UX, and constraints.
---
This page documents the current manual DOM editing surface in HyperFrames Studio. It reflects the implementation that ships in the Studio inspector today, not the earlier design draft that explored third-party transform engines.
## What Shipped
Studio now supports a direct DOM editing workflow inside the preview:
- select supported elements directly in the preview
- see an editor-owned overlay around the current selection
- move and resize supported elements on canvas when geometry is safe
- detach eligible layout-controlled layers with an explicit `Make movable` action
- edit style properties from the right-side `Design` inspector
- edit text layers for safe text-bearing selections, including empty text values
- add and remove child text layers for multi-text selections
- edit solid fills, gradients, project-asset image fills, external image fills, opacity, radius, flex metadata, typography, and blend mode
- drill into nested compositions from master view instead of pretending every inner node is editable in place
- generate an element-scoped `Ask agent` prompt bundle from the right inspector
The important rule is conservative: Studio only exposes interactions it can round-trip back to authored HTML with deterministic behavior.
## Current User Experience
### Preview selection
- Single click selects a patchable element in the preview.
- The selection overlay is rendered in Studio chrome, not injected into authored content.
- The overlay is cleared when:
- the `Inspector` panel is closed
- the user clicks an empty area in the preview
- the underlying element disappears after a source refresh
### Overlay behavior
The overlay provides:
- selection bounds
- drag behavior for supported elements
- a resize handle when width and height are safely patchable
- blocked-drag feedback for unsupported movement
The overlay intentionally does not include a floating action toolbar. `Ask agent` lives in the right inspector header, and style controls live in the `Design` panel.
The current implementation uses Studio-owned pointer handling in `DomEditOverlay.tsx`. It does **not** use `Moveable`.
### Inspector behavior
The `Design` panel currently includes:
- `Layout`
- X / Y / W / H fields
- wheel and arrow-key numeric scrubbing
- `Make movable` for block-ish layout-controlled layers that can be detached safely
- `Flex`
- direction, justify, align, gap, clip content
- `Radius`
- slider + live readout
- `Blending`
- opacity slider + live readout
- blend mode
- `Fill`
- solid color
- multi-stop gradient editing
- project asset image fills
- inline image upload into the project assets list
- external image URL fill
- text color
- `Color picker`
- viewport-clamped floating picker
- saturation / brightness crosshair
- hue and alpha sliders
- hex input
- `Text`
- direct text layer editing when the selection is safe to patch
- add / remove text layers for child text selections
- font size, weight, and family controls
- `Selection colors`
- a summary of detected colors for the current selection
The inspector is intentionally split from `Renders` with a `Design / Renders` tab control in the right panel. Switching to `Renders` does not mean the header-level `Inspector` panel is closed.
## What Counts As Editable
Studio builds a `DomEditSelection` and `DomEditCapabilities` object for each selection.
### Selection requirements
A node is only useful to Studio if it can be identified with a stable patch target, for example:
- `id`
- stable selector
- selector index scoped to the correct source file
- composition host mapping when master view is involved
### Move support
Move is allowed only when the selected element:
- has a stable patch target
- is `absolute` or `fixed`
- has `left` and `top` values that resolve to pixel values
- is not transform-driven (`transform: none`)
### Resize support
Resize is allowed only when move is already allowed and Studio can also safely patch pixel `width` and/or `height`.
### Detach from layout support
Some block-ish layers are selectable and style-editable, but cannot be moved directly because flex, grid, or normal document flow owns their position.
For those layers, Studio can expose `Make movable` instead of silently converting on drag. The action measures the current visual rect relative to the composition root and writes conservative inline geometry:
- `position: absolute`
- `left`, `top`, `width`, and `height` in pixels
- `margin: 0`
The UI explains that this detaches the layer from flex/grid flow and preserves the current visual position. Inline text nodes are not detached directly.
### Text editing support
Text editing is allowed only for safe text-bearing selections:
- supported text-bearing tags such as `div`, `span`, `p`, `strong`, and headings
- self text selections or leaf child text layers
- empty text values after a user clears the content
- not a composition host
For multi-text selections, Studio shows a text-layer list. Users can select a specific text layer, edit content live, change size, weight, and font family, add a sibling text layer, or remove the active layer.
### Unsupported examples
Studio intentionally withholds direct geometry editing for:
- flex/grid children whose position is emergent from layout, unless the user chooses `Make movable`
- transform-driven geometry
- nested composition internals while the user is still in master view
- nodes without a stable patch target
- inline text spans as geometry targets
When geometry is blocked but style edits are still safe, the inspector shows the selection and the reason direct geometry editing is unavailable.
If the user tries to drag a blocked layer, Studio shows a toast. Layout-owned layers point users to `Make movable`; transform-driven or unsafe targets explain that direct move/resize is limited to absolute or fixed pixel geometry with no transform-driven layout.
## Nested Composition Rules
Nested compositions are handled explicitly.
### In master view
- clicking content inside a nested composition maps back to the composition host
- supported composition hosts can move as a whole when their host geometry is safe
- Studio does not expose direct inner-node geometry edits from the master preview
- double click drills into the subcomposition
### After drill-down
- Studio resolves selections inside that composition normally
- direct move/resize becomes available again if the selected inner node meets the capability rules
- text, fill, gradient, image, radius, opacity, and typography edits apply to the selected inner node
This keeps Studio honest about what it can patch safely from the current editing context.
## Source Patching Model
Studio still uses authored HTML as the source of truth.
The manual DOM editing flow patches source through the existing patch pipeline in `packages/studio/src/utils/sourcePatcher.ts`.
Current patch types used by the inspector include:
- inline style patches
- attribute patches for timeline-linked editing paths
- text-content patches
- detach-from-layout style patches
The flow is:
1. user selects or manipulates an element in the preview
2. Studio resolves a stable target
3. the preview is updated optimistically for interaction feedback
4. the patch is written back to source
5. the preview refreshes and selection is reattached
## Gradient Editing
The current gradient editor is a structured Studio control, not a raw CSS text field.
It supports:
- `linear`, `radial`, and `conic` gradients
- repeating variants
- multiple stops
- stop insertion by clicking the preview strip
- stop removal
- angle control
- radial shape and size controls
- radial/conic center controls
The editor still serializes back to CSS `background-image`, but the inspector works with a parsed gradient model instead of forcing the user to type raw gradient syntax.
## Image Fill Editing
The image fill editor is no longer just a raw `background-image` input.
It supports:
- selecting an existing project image asset
- uploading an image from the fill panel, which also adds it to the Assets tab
- previewing the selected project asset in the panel
- entering an external URL when the image is not a project asset
Studio serializes project asset selections back to `background-image: url(...)`, and rewrites asset URLs so nested subcomposition previews still resolve the image correctly.
## Color Editing
The color editor is a custom Studio popover instead of the native browser color dialog.
It supports:
- opening from the whole color row
- staying inside the viewport near the clicked color
- saturation / brightness picking with visible crosshair guides
- hue and alpha controls with visible handles
- a current color swatch, readout, and hex input
The picker writes CSS `rgb(...)` or `rgba(...)` values and preserves alpha through edits.
## Numeric Scrubbing
Numeric layout/detail inputs support lightweight design-tool-style nudging:
- mouse wheel over the focused field
- `ArrowUp` / `ArrowDown`
- `Shift` for larger steps
- `Alt` for finer steps
This is currently used across the numeric commit fields in the inspector, including layout metrics and other numeric text inputs that parse cleanly as values plus units.
## Files That Own The Feature
The main implementation lives in:
- `packages/studio/src/App.tsx`
- overall inspector wiring
- selection lifecycle
- preview hit testing
- persistence hooks
- detach-from-layout commit flow
- `packages/studio/src/components/editor/DomEditOverlay.tsx`
- overlay box, drag, resize, blocked-drag feedback
- `packages/studio/src/components/editor/PropertyPanel.tsx`
- right-side inspector UI
- `packages/studio/src/components/editor/domEditing.ts`
- selection resolution
- capability gating
- text field modeling
- prompt generation
- `packages/studio/src/components/editor/colorValue.ts`
- color parsing, HSV conversion, and CSS color serialization
- `packages/studio/src/components/editor/floatingPanel.ts`
- viewport-safe floating panel placement for color picking
- `packages/studio/src/components/editor/fontAssets.ts`
- imported font asset helpers
- `packages/studio/src/components/editor/fontCatalog.ts`
- Google font catalog metadata and stylesheet URLs
- `packages/studio/src/components/editor/gradientValue.ts`
- gradient parsing, serialization, and stop editing helpers
- `packages/studio/src/utils/sourcePatcher.ts`
- source patch persistence
Supporting Studio shell changes also landed in:
- `packages/studio/src/components/nle/NLELayout.tsx`
- `packages/studio/src/components/nle/NLEPreview.tsx`
- `packages/studio/src/components/sidebar/CompositionsTab.tsx`
- `packages/studio/src/components/sidebar/LeftSidebar.tsx`
- `packages/studio/src/player/components/Player.tsx`
- `packages/studio/src/player/components/Timeline.tsx`
- `packages/studio/src/player/components/TimelineClip.tsx`
- `packages/studio/src/player/hooks/useTimelinePlayer.ts`
- `packages/studio/src/utils/mediaTypes.ts`
## Current Constraints
This feature is intentionally **not** a full general-purpose visual builder.
Still out of scope today:
- rotation
- arbitrary transforms
- snapping and alignment guides
- multi-select
- marquee selection
- freeform editing of every DOM node regardless of layout model
- editing nested subcomposition internals directly from the master preview without drill-down
- automatic conversion to absolute positioning on drag without user confirmation
- direct geometry editing of inline text spans
## Bottom Line
Studio manual DOM editing is now a narrow, deterministic visual editing layer over authored HTML.
It does **not** try to make the whole DOM freely editable. Instead it:
- keeps source HTML as the source of truth
- exposes only patchable interactions
- uses a Studio-owned overlay layer for direct manipulation
- gives users a real inspector for safe style and text edits
- treats nested compositions as drill-down boundaries instead of flattening them into an unsafe editing surface
That tradeoff is the reason the current feature feels reliable instead of deceptive.
+3 -1
View File
@@ -63,6 +63,7 @@
"pages": [
"concepts/compositions",
"concepts/data-attributes",
"concepts/variables",
"concepts/frame-adapters",
"concepts/determinism"
]
@@ -222,7 +223,8 @@
"contributing",
"contributing/catalog",
"contributing/release-channels",
"contributing/testing-local-changes"
"contributing/testing-local-changes",
"contributing/studio-manual-dom-editing"
]
},
{
+309
View File
@@ -0,0 +1,309 @@
---
title: HyperFrames MCP
description: "Author, preview, and render HyperFrames videos directly inside Claude.ai and ChatGPT — no local install required."
---
The HyperFrames MCP is a hosted [Model Context Protocol](https://modelcontextprotocol.io) server that lets you create, edit, preview, and render HyperFrames video compositions from inside Claude.ai or ChatGPT.
<Note>
**In beta.** Features and pricing may change. Found a bug or have feedback? [File an issue on GitHub](https://github.com/heygen-com/hyperframes/issues).
</Note>
## What you can do
- Create compositions from natural-language prompts
- Edit existing compositions conversationally — *"make the title 2x bigger"*, *"add hype-style captions"*
- Preview the result inline in the chat with a video player widget
- Render to `mp4`, `webm`, or `mov` — output URL streamed back to chat
- Revisit compositions you've created previously
- Check your render credits
The agent behind `compose` has 25+ HyperFrames-specific skills baked in — typography, color palettes, motion principles, GSAP effects, audio-reactive animation, captions, voice generation. You don't have to specify any of this directly; describe the video you want and the agent picks the right tools.
<Note>
Looking for the open-source CLI? See the [Quickstart](/quickstart). The MCP is a hosted product for zero-install authoring inside an LLM chat. The CLI gives you full control of rendering and runtime; the MCP gives you instant authoring with cloud rendering.
</Note>
## Setup
### 1. Get a HeyGen account
The MCP requires a HeyGen account for authentication and credits. Sign up at [heygen.com](https://heygen.com) if you don't have one.
### 2. Add the connector
<Tabs>
<Tab title="Claude.ai">
<Steps>
<Step title="Open Settings → Connectors">
In Claude.ai web or desktop: **Settings → Connectors → Add custom connector**.
</Step>
<Step title="Enter the URL">
Paste:
```
https://mcp.heygen.com/mcp/hyperframes
```
</Step>
<Step title="Sign in to HeyGen">
OAuth opens in a new window. Authorize the HyperFrames connector to access your HeyGen account.
</Step>
<Step title="Start a new chat">
Open a new Claude.ai chat. Try:
> Make me a 10-second product intro for [your product] with bouncy captions and a high-energy soundtrack.
</Step>
</Steps>
</Tab>
<Tab title="ChatGPT">
<Steps>
<Step title="Open Apps & Connectors">
In ChatGPT: **Settings → Apps & Connectors → Add MCP server**.
</Step>
<Step title="Enter the URL">
Paste:
```
https://mcp.heygen.com/mcp/hyperframes
```
</Step>
<Step title="Sign in to HeyGen">
Authorize via OAuth.
</Step>
<Step title="Start a new chat">
Open a new chat. Same prompts work as in Claude.ai.
</Step>
</Steps>
</Tab>
</Tabs>
## Available tools
The MCP exposes six tools to the LLM. You don't call them directly — the model picks the right tool based on your message.
| Tool | What it does | Cost |
|---|---|---|
| `compose` | Create a new composition or edit an existing one | Author credits |
| `list_compositions` | List your previously created compositions | Free |
| `get_composition` | Open a specific composition with an inline player | Free |
| `render_video` | Submit a cloud render to mp4 / webm / mov | Render credits |
| `get_render_status` | Poll a long-running render job | Free |
| `get_credits` | Check your remaining credits and tier | Free |
### compose
Authors a new composition or applies an edit to an existing one. The HyperFrames agent handles voice selection, captions, blocks, layout, transitions, color, and timing internally based on your natural-language prompt.
**Triggered by prompts like:**
- "Make a 30-second product intro about [topic]" → creates a fresh composition
- "Change the title font to a bold serif" → edits the most recent composition
- "Add a flash transition before the call-to-action" → applies a structured edit
**Returns:** A composition reference (id, title, thumbnail) plus an inline player widget. Progress notifications stream during the run so you can see what the agent is doing — *"Drafting outline...", "Selecting voice and style...", "Generating HTML...", "Rendering preview frame..."*
### list_compositions
Lists compositions you've previously created. Newest first, paginated.
**Triggered by prompts like:** *"show me my recent videos"*, *"what did I work on yesterday?"*
### get_composition
Fetches metadata for a single composition along with an inline player widget.
**Triggered by prompts like:** *"open that video again"*, *"show me the one I made about [topic]"*
### render_video
Submits a cloud render. Defaults to `mp4` at `30fps`.
**Format options:**
| Format | Codec | Use it for |
|---|---|---|
| `mp4` (default) | H.264 | Broadest compatibility, social media, web |
| `webm` | VP9 | Smaller files; supports alpha channel for transparent overlays |
| `mov` | ProRes | Lossless quality for editing pipelines |
**Frame rate options:** `24`, `30` (default), `60`.
**Returns:** Either the rendered video URL (if the render finishes within 25 seconds) or a `job_id` for polling. Either way, an inline render-progress widget shows live status.
**Triggered by prompts like:** *"render this"*, *"export to webm"*, *"render at 60fps for editing"*.
### get_render_status
Polls an in-progress render. Used internally by the model when `render_video` returns a `job_id` (long renders).
### get_credits
Returns your tier and remaining credits.
**Triggered by:** *"how many renders do I have left?"*, *"what's my plan?"*
## Prompting tips
### Be specific about what you want
The agent has lots of creative latitude — give it enough direction to use it well.
| Less effective | More effective |
|---|---|
| "make a video" | "make a 15-second TikTok hook about home composting with bouncy captions and a warm earthy palette" |
| "add captions" | "add hype-style captions in my brand color #FF6A00" |
| "make it shorter" | "trim to 10 seconds total — cut the third scene" |
| "more energetic" | "swap to a neon-electric palette and tighten all transitions to 200ms" |
### Iterate conversationally
Once a composition exists, the agent loads the current state and applies edits in place. Keep talking to it.
```
You: "20-second product intro for my app, dark theme, hype style"
Agent: [composition + player widget appears]
You: "make the logo bigger and add a pulse animation on the CTA"
Agent: [updated player widget]
You: "render to webm with alpha"
Agent: [render-progress widget, then player widget with download link]
```
### Reference your existing HeyGen assets
If you've uploaded logos, brand voices, fonts, or other assets to your HeyGen account, the agent can use them. Just say *"use my logo"* or *"use my Sarah brand voice."* The agent resolves the asset by name and recency.
### Pick the right format up front
Mention the output format if you have a specific use case:
- *"render to mp4"* — default, social media
- *"render to webm with alpha"* — transparent overlay you'll composite later
- *"render to mov for After Effects"* — ProRes for editing
## Debugging
### Inspect the MCP with MCP Inspector
For developers building integrations or debugging tool responses, the MCP Inspector lets you see exactly what tools are exposed and what they return:
```bash
npx @modelcontextprotocol/inspector npx -y mcp-remote https://mcp.heygen.com/mcp/hyperframes
```
Complete the OAuth flow in the inspector's browser tab. Once authenticated, you can call each tool with custom parameters and see raw responses, including `tool_data` and `widget_data`.
### Watch progress notifications
The MCP emits MCP `notifications/progress` events during long-running `compose` and `render_video` calls. The host (Claude.ai or ChatGPT) displays them inline:
```
You: "make me a 30-second product intro"
Agent: [calls compose]
↳ "Drafting outline..." ← progress notification
↳ "Selecting voice and style..." ← progress notification
↳ "Generating HTML..." ← progress notification
↳ "Rendering preview frame..." ← progress notification
↳ [composition + player widget]
Agent: "Here's your video — [player]"
```
If progress stops mid-flow, the run failed. The agent's next message should explain what went wrong.
### Common issues
<AccordionGroup>
<Accordion title="OAuth keeps failing or loops">
Verify you're using the production URL: `https://mcp.heygen.com/mcp/hyperframes`. The dev URL (`mcp.dev.heygen.com`) only accepts dev accounts.
If OAuth completes but you see "not authorized" errors, your HeyGen account may not have access to the MCP — contact support or check your tier.
</Accordion>
<Accordion title="Render is stuck or never completes">
Renders normally finish in 10-90 seconds depending on length, fps, and format.
If `get_render_status` shows `status: rendering` for more than 5 minutes, something has stuck. Try:
1. Start a new chat thread
2. Run `compose("regenerate this composition")` — the underlying composition may reference an asset that's failing to load
3. If the issue persists, file an issue at [github.com/heygen-com/hyperframes/issues](https://github.com/heygen-com/hyperframes/issues) including the `job_id` from `render_video`
</Accordion>
<Accordion title='"Composition not found" error'>
The `composition_id` is owned by the HeyGen space (account) that created it. If you're signed into a different space, you can't access compositions from another. Run `list_compositions` to see what's available from your current account.
</Accordion>
<Accordion title="Player widget shows blank or loads forever">
Usually a transient connection issue between the widget and `mcp.heygen.com`. Refresh the chat or call `get_composition` again.
If it persists, your composition may reference a media asset that failed to upload — recreate the composition with `compose("regenerate this")`.
</Accordion>
<Accordion title="Out of credits">
The MCP returns an error with an upgrade URL when your credits are exhausted. Visit [heygen.com/pricing](https://heygen.com/pricing) to upgrade your tier.
</Accordion>
<Accordion title="Tool call timed out in the host">
`compose` runs can take 30+ seconds for complex prompts. If your client times out before the response comes back, the underlying run is likely still progressing — wait 30 seconds and run `list_compositions`. The composition is probably already created.
</Accordion>
<Accordion title="Agent ignored part of my prompt">
The agent prefers structural decisions (palette, layout, motion) over fine-grained pixel positioning. If a specific edit doesn't land, try rephrasing more directively:
- Less effective: *"the title is a little off"*
- More effective: *"move the title 40px down and increase its weight to 800"*
For pixel-precise control, use the [open-source CLI](/quickstart) — the MCP is optimized for fast natural-language iteration.
</Accordion>
</AccordionGroup>
### Reporting issues
For bugs or feature requests, file an issue at [github.com/heygen-com/hyperframes/issues](https://github.com/heygen-com/hyperframes/issues). Include:
- The prompt you used
- The `composition_id` and `job_id` (if applicable) — these are visible in the tool response details
- A description of what you expected vs. what happened
- The host (Claude.ai web/desktop, ChatGPT, etc.)
## Limitations
- **Cloud-only rendering.** All renders run on HeyGen infrastructure. Use the [CLI](/quickstart) if you need local rendering.
- **Single-user.** Each composition is owned by one HeyGen account. No team sharing in v1.
- **No binary uploads from chat.** You can reference assets you've already uploaded to HeyGen via the web UI, but the MCP does not currently accept new file uploads through chat. Upload via [app.heygen.com](https://app.heygen.com) first, then reference the asset by name.
- **Aspect ratios:** `16:9`, `9:16`, `1:1`, `4:5`. Other ratios fall back to the closest match.
- **No fine-grained editing tools.** Edits go through the agent. For pixel-precise control, use the [CLI](/quickstart) and a coding agent like Claude Code or Cursor.
- **Widget rendering requires a host that supports MCP widgets.** Claude.ai web/desktop and ChatGPT (Apps SDK) support widgets today. Text-only MCP clients (Claude Code CLI, Cursor, Windsurf) will see a clickable preview URL instead — full text-mode support is on the roadmap.
## How this relates to the open-source framework
[HyperFrames itself is open source](https://github.com/heygen-com/hyperframes) — the HTML composition format, CLI, renderer, and player. You can use HyperFrames locally without the MCP.
The MCP is a HeyGen-hosted product that wraps:
- The HyperFrames composition agent (the LLM that authors compositions)
- HeyGen's cloud rendering pipeline
- HeyGen's voice / TTS / asset libraries
- OAuth, credits, and tier management
| You want… | Use… |
|---|---|
| Zero local install, fast natural-language authoring | The MCP |
| Pixel-precise control, custom rendering, self-hosting | The [CLI](/quickstart) |
| Both | Author with the MCP, then download and refine with the CLI (planned export feature) |
## Next steps
<CardGroup cols={2}>
<Card title="Quickstart" href="/quickstart">
Try HyperFrames locally with the open-source CLI.
</Card>
<Card title="Prompting guide" href="/guides/prompting">
Tips for getting the best results when working with AI agents.
</Card>
<Card title="Catalog" href="/catalog/blocks/data-chart">
Browse 50+ ready-to-use blocks the agent draws from.
</Card>
<Card title="Examples" href="/examples">
Reference compositions you can clone.
</Card>
</CardGroup>
+3 -3
View File
@@ -164,8 +164,8 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| Flag | Description |
|------|-------------|
| `--example, -e` | Example to scaffold (required in non-interactive mode, prompted on TTY by default) |
| `--resolution` | Canvas preset: `landscape` (1920×1080), `portrait` (1080×1920), `landscape-4k` (3840×2160), `portrait-4k` (2160×3840). Aliases: `1080p`, `4k`, `uhd`. Default: keep template dimensions. |
| `--example, -e` | Example to scaffold (required in default mode, interactive in `--human-friendly`) |
| `--resolution` | Canvas preset: `landscape` (1920×1080), `portrait` (1080×1920), `landscape-4k` (3840×2160), `portrait-4k` (2160×3840), `square` (1080×1080), `square-4k` (2160×2160). Aliases: `1080p`, `4k`, `uhd`, `1080p-square`, `square-1080p`, `4k-square`. Default: keep template dimensions. |
| `--video, -V` | Path to a video file (MP4, WebM, MOV) |
| `--audio, -a` | Path to an audio file (MP3, WAV, M4A) |
| `--tailwind` | Add Tailwind CSS browser-runtime support to scaffolded HTML |
@@ -612,7 +612,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--quality` | draft, standard, high | standard | Encoding quality preset (drives CRF/bitrate) |
| `--crf` | 0-51 | — | Override encoder CRF (lower = higher quality). Mutually exclusive with `--video-bitrate` |
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target video bitrate. Mutually exclusive with `--crf` |
| `--resolution` | landscape, portrait, landscape-4k, portrait-4k (aliases: `1080p`, `4k`, `uhd`) | — | Output resolution preset. Supersamples a smaller composition via Chrome `deviceScaleFactor` so the screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not supported with `--hdr`. See [4K Rendering](/guides/4k-rendering) |
| `--resolution` | landscape, portrait, landscape-4k, portrait-4k, square, square-4k (aliases: `1080p`, `4k`, `uhd`, `1080p-square`, `square-1080p`, `4k-square`) | — | Output resolution preset. Supersamples a smaller composition via Chrome `deviceScaleFactor` so the screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not supported with `--hdr`. See [4K Rendering](/guides/4k-rendering) |
| `--hdr` | — | off | Force HDR output even if no HDR sources are detected. MP4 only. See [HDR Rendering](/guides/hdr) |
| `--sdr` | — | off | Force SDR output even if HDR sources are detected |
| `--workers` | 1-8 | 4 | Parallel render workers |
+1 -1
View File
@@ -51,7 +51,7 @@ import type {
TimelineElementType, // "video" | "image" | "text" | "audio" | "composition"
CompositionSpec,
CompositionVariable,
CanvasResolution, // "landscape" | "portrait"
CanvasResolution, // "landscape" | "portrait" | "landscape-4k" | "portrait-4k" | "square" | "square-4k"
Orientation, // "16:9" | "9:16"
FrameAdapter,
FrameAdapterContext,
+16
View File
@@ -12,6 +12,22 @@ pre-commit:
typecheck:
glob: "*.{ts,tsx}"
run: cd packages/core && bunx tsc --noEmit && cd ../studio && bunx tsc --noEmit
filesize:
# Scoped to packages/studio — the 500 LOC limit is a studio architecture
# standard enforced as part of the App.tsx decomposition work. Player and
# other packages enforce size discipline via code review and convention.
# Files temporarily over the limit are listed in .filesize-allowlist.
glob: "packages/studio/**/*.{ts,tsx}"
exclude: "(\\.test\\.(ts|tsx)$|\\.generated\\.)"
run: |
for f in {staged_files}; do
if grep -qxF "$f" .filesize-allowlist 2>/dev/null; then continue; fi
lines=$(wc -l < "$f")
if [ "$lines" -gt 500 ]; then
echo "ERROR: $f has $lines lines (max 500) — add to .filesize-allowlist if temporarily needed"
exit 1
fi
done
commit-msg:
commands:
+12
View File
@@ -47,5 +47,17 @@
"oxlint": "^1.56.0",
"tsx": "^4.21.0",
"typescript": "^5.0.0"
},
"resolutions": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"overrides": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/cli",
"version": "0.5.7",
"version": "0.6.4",
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
"repository": {
"type": "git",
@@ -18,7 +18,7 @@
"test": "vitest run",
"dev": "tsx src/cli.ts",
"build": "bun run build:fonts && tsup && bun run build:runtime && bun run build:copy",
"build:fonts": "cd ../producer && tsx scripts/generate-font-data.ts",
"build:fonts": "node -e \"const fs=require('fs');if(!fs.existsSync('../producer/src/services/fontData.generated.ts')){const {execSync}=require('child_process');execSync('cd ../producer && tsx scripts/generate-font-data.ts',{stdio:'inherit'})}else{console.log('[build:fonts] skipped — fontData.generated.ts already exists')}\"",
"build:runtime": "tsx scripts/build-runtime.ts",
"build:copy": "node scripts/build-copy.mjs",
"typecheck": "tsc --noEmit"
@@ -29,7 +29,7 @@
"adm-zip": "^0.5.16",
"citty": "^0.2.1",
"compare-versions": "^6.1.1",
"esbuild": "^0.25.0",
"esbuild": "^0.25.12",
"giget": "^3.2.0",
"hono": "^4.0.0",
"mime-types": "^3.0.2",
@@ -48,7 +48,7 @@
"@hyperframes/studio": "workspace:*",
"@types/adm-zip": "^0.5.7",
"@types/mime-types": "^3.0.1",
"@types/node": "^22.0.0",
"@types/node": "^25.0.10",
"linkedom": "^0.18.12",
"picocolors": "^1.1.1",
"tsup": "^8.0.0",
+60 -1
View File
@@ -1,4 +1,4 @@
import { execSync } from "node:child_process";
import { execSync, spawnSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -111,6 +111,55 @@ export async function findBrowser(): Promise<BrowserResult | undefined> {
return findFromSystem();
}
/**
* On Linux ARM64, attempt to auto-install system Chromium if not found.
* This makes `hyperframes render` work out-of-the-box on DGX Spark / GB10 / Jetson.
*/
async function ensureLinuxArmBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
void options;
// If already available (env var or system path), use it directly.
const existing = await findBrowser();
if (existing) return existing;
// Try auto-installing via apt (common on Ubuntu-based ARM systems).
const hasApt = existsSync("/usr/bin/apt-get");
if (hasApt) {
console.error(
"\n🔍 Linux ARM64 detected — Chrome Headless Shell is not available for this platform.",
);
console.error("📦 Auto-installing system Chromium via apt-get (this only happens once)...\n");
// Use spawnSync so output streams to the terminal in real time.
const result = spawnSync("apt-get", ["install", "-y", "chromium-browser"], {
stdio: "inherit",
timeout: 120_000,
});
if (result.status === 0) {
const afterInstall = await findBrowser();
if (afterInstall) {
console.error(`\n✅ Chromium installed at ${afterInstall.executablePath}\n`);
return afterInstall;
}
} else {
// apt succeeded but binary not found, or apt failed — fall through to helpful error.
console.error("\n⚠️ apt-get exited with errors. Trying anyway...\n");
const afterAttempt = await findBrowser();
if (afterAttempt) return afterAttempt;
}
}
// Could not auto-install — give clear manual instructions.
throw new Error(
`Chrome Headless Shell is not available for Linux ARM64 (DGX Spark, GB10, Jetson).\n\n` +
`Install Chromium manually and point hyperframes to it:\n\n` +
` sudo apt-get install -y chromium-browser\n` +
` export HYPERFRAMES_BROWSER_PATH=$(which chromium-browser)\n\n` +
`Then re-run your command. The HYPERFRAMES_BROWSER_PATH env var persists for the session.`,
);
}
/**
* Find or download a browser.
* Resolution: env var -> cached download -> system Chrome -> auto-download.
@@ -124,6 +173,12 @@ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<Bro
throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`);
}
// Chrome headless shell has no Linux ARM64 build (e.g. DGX Spark, GB10).
// Try to auto-install system Chromium via apt, then find it.
if (isLinuxArm()) {
return ensureLinuxArmBrowser(options);
}
const installed = await install({
cacheDir: CACHE_DIR,
browser: Browser.CHROMEHEADLESSSHELL,
@@ -147,4 +202,8 @@ export function clearBrowser(): boolean {
return true;
}
export function isLinuxArm(): boolean {
return detectBrowserPlatform() === "linux_arm";
}
export { CHROME_VERSION, CACHE_DIR };
+1 -1
View File
@@ -157,7 +157,7 @@ export async function renderLottiePreviews(
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
<style>*{margin:0;padding:0;background:transparent}#c{width:400px;height:400px}</style>
</head><body><div id="c"></div></body></html>`,
{ waitUntil: "networkidle0", timeout: 10000 },
{ waitUntil: "load", timeout: 10000 },
);
// Pass animation data safely via parameterized evaluate (no string interpolation)
await previewPage.evaluate(
+35
View File
@@ -15,12 +15,47 @@ import {
clearBrowser,
CHROME_VERSION,
CACHE_DIR,
isLinuxArm,
} from "../browser/manager.js";
import { trackBrowserInstall } from "../telemetry/events.js";
async function runEnsure(): Promise<void> {
clack.intro(c.bold("hyperframes browser ensure"));
// ARM64 Linux: Chrome headless shell is not available.
// Try to find system Chromium first, then attempt auto-install via apt.
if (isLinuxArm()) {
const s = clack.spinner();
s.start("Linux ARM64 detected — looking for system Chromium...");
const existing = await findBrowser();
if (existing) {
s.stop(c.success("System Chromium found"));
console.log();
console.log(` ${c.dim("Source:")} ${c.bold(existing.source)}`);
console.log(` ${c.dim("Path:")} ${c.bold(existing.executablePath)}`);
console.log();
clack.outro(c.success("Ready to render."));
return;
}
s.stop(c.warn("No Chromium found — attempting auto-install via apt-get..."));
console.log();
// Delegate to ensureBrowser which handles the full ARM64 install flow.
try {
const result = await ensureBrowser();
console.log();
console.log(` ${c.dim("Source:")} ${c.bold(result.source)}`);
console.log(` ${c.dim("Path:")} ${c.bold(result.executablePath)}`);
console.log();
clack.outro(c.success("Chromium ready. You can now render on ARM64."));
} catch (err) {
clack.log.error(err instanceof Error ? err.message : String(err));
clack.outro(c.warn("Manual setup required (see instructions above)."));
}
return;
}
const s = clack.spinner();
s.start("Looking for an existing browser...");
+32
View File
@@ -247,6 +247,38 @@ describe("applyResolutionPreset", () => {
});
});
it("swaps to square dimensions for square", () => {
withFixture((dir) => {
const file = join(dir, "index.html");
writeFileSync(file, sampleHtml, "utf-8");
applyResolutionPreset(dir, "square");
const out = readFileSync(file, "utf-8");
expect(out).toContain('data-width="1080"');
expect(out).toContain('data-height="1080"');
expect(out).toContain('data-resolution="square"');
expect(out).toContain("width: 1080px");
expect(out).toContain("height: 1080px");
});
});
it("swaps to square-4k dimensions", () => {
withFixture((dir) => {
const file = join(dir, "index.html");
writeFileSync(file, sampleHtml, "utf-8");
applyResolutionPreset(dir, "square-4k");
const out = readFileSync(file, "utf-8");
expect(out).toContain('data-width="2160"');
expect(out).toContain('data-height="2160"');
expect(out).toContain('data-resolution="square-4k"');
expect(out).toContain("width: 2160px");
expect(out).toContain("height: 2160px");
});
});
it("scaffolds a 4k project end-to-end via --resolution 4k", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
const target = join(dir, "proj");
+8 -5
View File
@@ -296,7 +296,7 @@ function patchVideoSrc(
): void {
const htmlFiles = readdirSync(dir, { withFileTypes: true, recursive: true })
.filter((e) => e.isFile() && e.name.endsWith(".html"))
.map((e) => join(e.parentPath ?? e.path, e.name));
.map((e) => join(e.parentPath, e.name));
for (const file of htmlFiles) {
let content = readFileSync(file, "utf-8");
@@ -510,9 +510,11 @@ async function scaffoldProject(
): Promise<void> {
mkdirSync(destDir, { recursive: true });
// Use bundled template if available, otherwise fetch from GitHub
// Use bundled template if available, otherwise fetch from GitHub.
// Check for index.html inside the dir — an empty directory left by the
// build toolchain should not prevent the remote fetch fallback.
const templateDir = getStaticTemplateDir(templateId);
if (existsSync(templateDir)) {
if (existsSync(join(templateDir, "index.html"))) {
cpSync(templateDir, destDir, { recursive: true });
} else {
await fetchRemoteTemplate(templateId, destDir);
@@ -630,7 +632,7 @@ export default defineCommand({
resolution: {
type: "string",
description:
"Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd. Default: keep template dimensions (typically 1920x1080).",
"Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square. Default: keep template dimensions (typically 1920x1080).",
},
},
async run({ args }) {
@@ -670,7 +672,8 @@ export default defineCommand({
console.error(
c.error(
`Invalid --resolution: "${args.resolution}". ` +
`Use one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k (or aliases 1080p, 4k, uhd).`,
`Use one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k ` +
`(or aliases 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square).`,
),
);
process.exit(1);
+3 -2
View File
@@ -220,7 +220,7 @@ export default defineCommand({
resolution: {
type: "string",
description:
"Output resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd. The composition is unchanged — Chrome renders at higher DPR (deviceScaleFactor) so the captured screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not yet supported with --hdr.",
"Output resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square. The composition is unchanged — Chrome renders at higher DPR (deviceScaleFactor) so the captured screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not yet supported with --hdr.",
},
},
async run({ args }) {
@@ -263,7 +263,8 @@ export default defineCommand({
if (!outputResolution) {
errorBox(
"Invalid resolution",
`Got "${args.resolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k (or aliases 1080p, 4k, uhd).`,
`Got "${args.resolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k ` +
`(or aliases 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square).`,
);
process.exit(1);
}
+50 -3
View File
@@ -13,6 +13,7 @@ import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
import { loadRuntimeSource } from "./runtimeSource.js";
import { VERSION as version } from "../version.js";
import {
createStudioManualEditsRenderBodyScript,
createStudioApi,
createProjectSignature,
getMimeType,
@@ -23,6 +24,8 @@ import {
import { getElementScreenshotClip } from "@hyperframes/core/studio-api/screenshot-clip";
import type { ScreenshotClip } from "@hyperframes/core/studio-api/screenshot-clip";
const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
// ── Path resolution ─────────────────────────────────────────────────────────
function resolveDistDir(): string {
@@ -78,6 +81,38 @@ function resolveRuntimePath(): string {
return builtPath;
}
function readStudioManualEditManifestContent(projectDir: string): string {
const manifestPath = join(projectDir, STUDIO_MANUAL_EDITS_PATH);
if (!existsSync(manifestPath)) return "";
try {
return readFileSync(manifestPath, "utf-8");
} catch {
return "";
}
}
async function applyStudioManualEditsToThumbnailPage(
page: import("puppeteer-core").Page,
manifestContent: string,
activeCompositionPath: string,
): Promise<void> {
const script = createStudioManualEditsRenderBodyScript(manifestContent, {
activeCompositionPath,
});
if (!script) return;
await page.addScriptTag({ content: script });
}
async function reapplyStudioManualEditsToThumbnailPage(
page: import("puppeteer-core").Page,
): Promise<void> {
await page.evaluate(() => {
const apply = (window as Window & { __hfStudioManualEditsApply?: () => number })
.__hfStudioManualEditsApply;
if (typeof apply === "function") apply();
});
}
// ── Shared thumbnail browser (singleton per process) ────────────────────────
// One browser instance is reused across all composition thumbnail requests.
// Spawning a new Puppeteer process per request adds 2-5s overhead and causes
@@ -174,6 +209,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
}
},
async transformPreviewHtml({ html }) {
const { injectDeterministicFontFaces } =
await import("../../../producer/src/services/deterministicFonts.js");
return injectDeterministicFontFaces(html);
},
getProjectSignature(dir: string): string {
if (resolve(dir) !== resolve(projectDir)) return createProjectSignature(dir);
cachedProjectSignature ??= createProjectSignature(projectDir);
@@ -212,6 +253,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
// Continue without — acquireBrowser will try its own resolution
}
const manifestContent = readStudioManualEditManifestContent(opts.project.dir);
const manualEditsRenderScript = createStudioManualEditsRenderBodyScript(manifestContent);
const job = createRenderJob({
// opts.fps is already an Fps rational — see vite-config-studio
// adapter for the same convention.
@@ -219,6 +262,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
quality: opts.quality as "draft" | "standard" | "high",
format: opts.format,
outputResolution: opts.outputResolution,
...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}),
});
const startTime = Date.now();
const onProgress = (j: { progress: number; currentStage?: string }) => {
@@ -275,11 +319,14 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
win.__timeline.seek(t);
}
}, opts.seekTime);
const manifestContent = readStudioManualEditManifestContent(opts.project.dir);
await applyStudioManualEditsToThumbnailPage(page, manifestContent, opts.compPath);
// Let the seek render settle.
await new Promise((r) => setTimeout(r, 200));
await reapplyStudioManualEditsToThumbnailPage(page);
let clip: ScreenshotClip | undefined;
if (opts.selector) {
clip = await page.evaluate(getElementScreenshotClip, opts.selector);
clip = await page.evaluate(getElementScreenshotClip, opts.selector, opts.selectorIndex);
}
const screenshot = (await page.screenshot(
opts.format === "png"
@@ -335,8 +382,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
app.get("/api/events", (c) => {
return streamSSE(c, async (stream) => {
const listener = () => {
stream.writeSSE({ event: "file-change", data: "{}" }).catch(() => {});
const listener = (path: string) => {
stream.writeSSE({ event: "file-change", data: JSON.stringify({ path }) }).catch(() => {});
};
watcher.addListener(listener);
while (true) {
+1 -1
View File
@@ -332,7 +332,7 @@ export function patchCaptionHtml(dir: string, words: Word[]): void {
try {
htmlFiles = readdirSync(dir, { withFileTypes: true, recursive: true })
.filter((e) => e.isFile() && e.name.endsWith(".html"))
.map((e) => join(e.parentPath ?? e.path, e.name));
.map((e) => join(e.parentPath, e.name));
} catch {
return;
}
+18 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/core",
"version": "0.5.7",
"version": "0.6.4",
"description": "",
"repository": {
"type": "git",
@@ -42,6 +42,14 @@
"import": "./src/studio-api/helpers/screenshotClip.ts",
"types": "./src/studio-api/helpers/screenshotClip.ts"
},
"./studio-api/manual-edits-render-script": {
"import": "./src/studio-api/helpers/manualEditsRenderScript.ts",
"types": "./src/studio-api/helpers/manualEditsRenderScript.ts"
},
"./studio-api/studio-motion-render-script": {
"import": "./src/studio-api/helpers/studioMotionRenderScript.ts",
"types": "./src/studio-api/helpers/studioMotionRenderScript.ts"
},
"./text": {
"import": "./src/text/index.ts",
"types": "./src/text/index.ts"
@@ -81,6 +89,14 @@
"import": "./dist/studio-api/helpers/screenshotClip.js",
"types": "./dist/studio-api/helpers/screenshotClip.d.ts"
},
"./studio-api/manual-edits-render-script": {
"import": "./dist/studio-api/helpers/manualEditsRenderScript.js",
"types": "./dist/studio-api/helpers/manualEditsRenderScript.d.ts"
},
"./studio-api/studio-motion-render-script": {
"import": "./dist/studio-api/helpers/studioMotionRenderScript.js",
"types": "./dist/studio-api/helpers/studioMotionRenderScript.d.ts"
},
"./text": {
"import": "./dist/text/index.js",
"types": "./dist/text/index.d.ts"
@@ -123,7 +139,7 @@
},
"devDependencies": {
"@types/jsdom": "^28.0.0",
"@types/node": "^24.10.13",
"@types/node": "^25.0.10",
"@vitest/coverage-v8": "^3.2.4",
"jsdom": "^29.0.0",
"tsx": "^4.21.0",
@@ -367,9 +367,11 @@ describe("bundleToSingleHtml", () => {
const host = document.querySelector("#scene-host");
expect(host?.getAttribute("data-composition-id")).toBe("scene");
expect(host?.getAttribute("data-composition-file")).toBe("compositions/scene.html");
expect(host?.getAttribute("data-start")).toBe("intro");
expect(host?.getAttribute("data-width")).toBe("1920");
expect(host?.querySelector(".title")?.textContent).toBe("Scene");
expect(host?.querySelector(".title")?.closest("[data-composition-file]")).toBe(host);
expect(
Array.from(host?.children ?? []).some(
(child) => child.getAttribute("data-composition-id") === "scene",
+18 -1
View File
@@ -7,7 +7,11 @@ import {
parseHTMLContent,
stripEmbeddedRuntimeScripts,
} from "./htmlDocument";
import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths";
import {
rewriteAssetPaths,
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "./rewriteSubCompPaths";
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
import { validateHyperframeHtmlContract } from "./staticGuard";
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
@@ -556,18 +560,31 @@ export async function bundleToSingleHtml(
el.setAttribute(attr, val);
},
);
const styledEls = innerRoot
? innerRoot.querySelectorAll("[style]")
: contentDoc.querySelectorAll("[style]");
rewriteInlineStyleAssetUrls(
styledEls,
src,
(el: Element) => el.getAttribute("style"),
(el: Element, val: string) => {
el.setAttribute("style", val);
},
);
if (innerRoot) {
const innerW = innerRoot.getAttribute("data-width");
const innerH = innerRoot.getAttribute("data-height");
if (innerW && !hostEl.getAttribute("data-width")) hostEl.setAttribute("data-width", innerW);
if (innerH && !hostEl.getAttribute("data-height")) hostEl.setAttribute("data-height", innerH);
innerRoot.setAttribute("data-composition-file", src);
for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || "";
} else {
for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();
hostEl.innerHTML = contentDoc.body.innerHTML || "";
}
hostEl.setAttribute("data-composition-file", src);
hostEl.removeAttribute("data-composition-src");
}
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { rewriteAssetPath, rewriteCssAssetUrls } from "./rewriteSubCompPaths.js";
import {
rewriteAssetPath,
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "./rewriteSubCompPaths.js";
describe("rewriteAssetPath", () => {
it("rewrites `../` against the sub-composition dir", () => {
@@ -36,4 +40,19 @@ describe("rewriteAssetPath", () => {
expect(out).not.toMatch(/\\/);
expect(out).not.toMatch(/:\\/);
});
it("rewrites CSS urls inside inline style attributes", () => {
const elements = [{ style: `background-image: url("../cover.png")` }];
rewriteInlineStyleAssetUrls(
elements,
"compositions/scene.html",
(el) => el.style,
(el, value) => {
el.style = value;
},
);
expect(elements[0]?.style).toBe(`background-image: url("cover.png")`);
});
});
@@ -96,6 +96,28 @@ export function rewriteAssetPaths<T>(
}
}
/**
* Rewrite CSS url(...) references inside inline style attributes.
*/
export function rewriteInlineStyleAssetUrls<T>(
elements: Iterable<T>,
compSrcPath: string,
getStyle: (el: T) => string | null | undefined,
setStyle: (el: T, value: string) => void,
): void {
const compDir = dirname(compSrcPath);
if (!compDir || compDir === ".") return;
for (const el of elements) {
const style = getStyle(el);
if (!style) continue;
const rewritten = rewriteCssAssetUrls(style, compSrcPath);
if (rewritten !== style) {
setStyle(el, rewritten);
}
}
}
/**
* Rewrite CSS url(...) references in a sub-composition's inline styles so
* ../foo.woff2 remains valid after the CSS is hoisted into the root document.
@@ -50,6 +50,28 @@ describe("caption rules", () => {
expect(finding).toBeUndefined();
});
it("does not warn for generic GSAP opacity exits in non-caption loops", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
var sceneCaption = document.querySelector("#scene-caption");
CARDS.forEach(function(group, gi) {
var groupEl = document.createElement("div");
groupEl.id = "card-" + gi;
tl.to(groupEl, { opacity: 0, duration: 0.12 }, 2);
});
window.__timelines["main"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("warns when caption group has nowrap without max-width", () => {
const html = `
<html><body>
+2 -1
View File
@@ -12,7 +12,8 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
content,
);
const hasCaptionLoop =
/forEach|\.forEach\s*\(/.test(content) && /createElement|caption|group|cg-/.test(content);
/forEach|\.forEach\s*\(/.test(content) &&
/karaoke|caption[-_]?(?:group|word|line|block)|cg-/.test(content);
if (hasCaptionLoop && hasExitTween && !hasHardKill) {
findings.push({
code: "caption_exit_missing_hard_kill",
+16 -1
View File
@@ -197,6 +197,21 @@ describe("parseHtml", () => {
expect(result.resolution).toBe("portrait");
});
it("keeps explicit portrait resolution even when dimensions are square", () => {
const html = `
<html data-resolution="portrait" data-composition-width="1080" data-composition-height="1080">
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("portrait");
});
it("defaults to portrait when no resolution info is available", () => {
const html = `
<html>
@@ -290,7 +305,7 @@ describe("parseHtml", () => {
expect(result.resolution).toBe("square");
});
it("infers square-4k from equal width/height ≥ 2160", () => {
it("infers square-4k from equal width/height >= 2160", () => {
const html = `
<html data-composition-width="2160" data-composition-height="2160">
<body>
+2 -2
View File
@@ -147,8 +147,8 @@ function parseResolutionFromHtml(doc: Document): CanvasResolution | null {
function resolveResolutionFromDimensions(width: number, height: number): CanvasResolution {
const longSide = Math.max(width, height);
// UHD cutoff is the long side of the 4K presets (3840 for `landscape-4k` /
// `portrait-4k`, 2160 for `square-4k`). A looser threshold (e.g. ≥ 2560)
// would silently misclassify QHD/1440p (2560×1440) as 4K, which is the
// `portrait-4k`, 2160 for `square-4k`). A looser threshold (e.g. >= 2560)
// would silently misclassify QHD/1440p (2560x1440) as 4K, which is the
// wrong default for a common authoring resolution closer to 1080p than to
// UHD. Authors who genuinely want the 4K preset can still set
// `data-resolution="..."` explicitly.
@@ -0,0 +1,36 @@
import { swallow } from "../diagnostics";
/**
* Shared, deduplicated `"hf-seek"` CustomEvent dispatcher for GPU adapters.
*
* Both the Three.js and TypeGPU adapters dispatch the same `"hf-seek"` event
* so that compositions need not know which GPU library they're paired with.
* Without deduplication, a seek to time T would fire two events (one from each
* adapter), doubling per-scrub work in any composition that has both present.
*
* This module deduplicates by tracking the last dispatched time. If the same
* time value is dispatched twice in the same synchronous call stack (e.g. two
* adapters both calling `dispatchSeekEvent(5.0)` without yielding), only the
* first call fires the event.
*
* The deduplication is intentionally coarse (exact float equality). Adapter
* seek paths clamp and normalise time before calling this function, so the
* values that arrive here are already stable.
*/
let _lastDispatchedTime = -1;
export function dispatchSeekEvent(time: number): void {
if (time === _lastDispatchedTime) return;
_lastDispatchedTime = time;
try {
window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time } }));
} catch (err) {
swallow("runtime.adapters.seek-dispatch.site1", err);
}
}
/** Reset internal state — used in tests to prevent cross-test contamination. */
export function resetSeekDispatchState(): void {
_lastDispatchedTime = -1;
}
@@ -1,11 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createThreeAdapter } from "./three";
import { resetSeekDispatchState } from "./seek-dispatch";
const threeWindow = window as Window & { __hfThreeTime?: number };
describe("three adapter", () => {
beforeEach(() => {
delete threeWindow.__hfThreeTime;
resetSeekDispatchState();
});
it("has correct name", () => {
+2 -7
View File
@@ -1,5 +1,5 @@
import type { RuntimeDeterministicAdapter } from "../types";
import { swallow } from "../diagnostics";
import { dispatchSeekEvent } from "./seek-dispatch";
export function createThreeAdapter(): RuntimeDeterministicAdapter {
let forcedTime: number | null = null;
@@ -12,12 +12,7 @@ export function createThreeAdapter(): RuntimeDeterministicAdapter {
forcedTime = Math.max(0, Number(ctx.time) || 0);
lastForcedTime = forcedTime;
window.__hfThreeTime = forcedTime;
try {
window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time: forcedTime } }));
} catch (err) {
// ignore custom event failures
swallow("runtime.adapters.three.site1", err);
}
dispatchSeekEvent(forcedTime);
},
pause: () => {
if (forcedTime == null) {
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createTypegpuAdapter } from "./typegpu";
import { resetSeekDispatchState } from "./seek-dispatch";
const gpuWindow = window as Window & { __hfTypegpuTime?: number };
describe("typegpu adapter", () => {
beforeEach(() => {
delete gpuWindow.__hfTypegpuTime;
// Reset shared dedup state so each test starts with a clean dispatch history
resetSeekDispatchState();
});
it("has correct name", () => {
expect(createTypegpuAdapter().name).toBe("typegpu");
});
it("seek sets __hfTypegpuTime", () => {
const adapter = createTypegpuAdapter();
adapter.seek({ time: 5 });
expect(gpuWindow.__hfTypegpuTime).toBe(5);
});
it("seek dispatches hf-seek custom event with time", () => {
const adapter = createTypegpuAdapter();
const handler = vi.fn();
window.addEventListener("hf-seek", handler);
adapter.seek({ time: 3.5 });
window.removeEventListener("hf-seek", handler);
expect(handler).toHaveBeenCalledOnce();
const detail = (handler.mock.calls[0][0] as CustomEvent).detail;
expect(detail.time).toBe(3.5);
});
it("seek clamps negative time to 0", () => {
const adapter = createTypegpuAdapter();
adapter.seek({ time: -5 });
expect(gpuWindow.__hfTypegpuTime).toBe(0);
});
it("seek handles NaN gracefully", () => {
const adapter = createTypegpuAdapter();
adapter.seek({ time: NaN });
expect(gpuWindow.__hfTypegpuTime).toBe(0);
});
it("multiple seeks to different times dispatch separate events", () => {
const adapter = createTypegpuAdapter();
const handler = vi.fn();
window.addEventListener("hf-seek", handler);
adapter.seek({ time: 1 });
adapter.seek({ time: 2 });
adapter.seek({ time: 3 });
window.removeEventListener("hf-seek", handler);
expect(handler).toHaveBeenCalledTimes(3);
});
it("duplicate seek to same time fires event only once (dedup)", () => {
const adapter = createTypegpuAdapter();
const handler = vi.fn();
window.addEventListener("hf-seek", handler);
adapter.seek({ time: 5 });
adapter.seek({ time: 5 }); // same time — deduplicated
window.removeEventListener("hf-seek", handler);
expect(handler).toHaveBeenCalledOnce();
// __hfTypegpuTime is still updated on every seek regardless of dedup
expect(gpuWindow.__hfTypegpuTime).toBe(5);
});
it("pause after seek preserves last time", () => {
const adapter = createTypegpuAdapter();
adapter.seek({ time: 8 });
adapter.pause();
expect(gpuWindow.__hfTypegpuTime).toBe(8);
});
it("revert resets state", () => {
const adapter = createTypegpuAdapter();
adapter.seek({ time: 5 });
adapter.revert!();
adapter.pause();
expect(gpuWindow.__hfTypegpuTime).toBe(5);
});
it("discover is a no-op and does not throw", () => {
const adapter = createTypegpuAdapter();
expect(() => adapter.discover()).not.toThrow();
});
});
@@ -0,0 +1,97 @@
import type { RuntimeDeterministicAdapter } from "../types";
import { dispatchSeekEvent } from "./seek-dispatch";
/**
* TypeGPU / WebGPU adapter for HyperFrames
*
* Enables seekable GPU-rendered compositions built with TypeGPU or raw WebGPU.
* Since WebGPU pipelines are not introspectable from outside (unlike GSAP
* timelines or Lottie instances), this adapter uses the same push+poll pattern
* as the Three.js adapter:
*
* - `window.__hfTypegpuTime` — poll this from your rAF/render loop instead
* of `performance.now()` to get the current seek position in seconds.
*
* - `"hf-seek"` CustomEvent on `window` — listen for this to imperatively
* re-render a single frame at the new seek position.
*
* ## Usage in a composition
*
* ```html
* <canvas id="gpu-canvas" width="1920" height="1080"></canvas>
* <script type="module">
* const adapter = await navigator.gpu.requestAdapter();
* const device = await adapter.requestDevice();
* // ... build your pipeline ...
*
* function render(timeSeconds) {
* // update your time uniform and submit a draw call
* device.queue.writeBuffer(uniformBuf, 0, new Float32Array([timeSeconds]));
* // ... submit command encoder ...
* }
*
* // Seek: fired by HyperFrames whenever the player scrubs or plays
* window.addEventListener("hf-seek", (e) => render(e.detail.time));
*
* // Initial frame at t=0
* render(window.__hfTypegpuTime ?? 0);
* </script>
* ```
*
* Works with TypeGPU (https://docs.swmansion.com/TypeGPU) and raw WebGPU alike.
* The adapter makes no assumptions about how the pipeline is constructed.
* Multiple canvases / renderers are supported — each listens for the same event.
*
* ## Render-mode determinism
*
* For frame-perfect video renders, call `await device.queue.onSubmittedWorkDone()`
* after each `render(time)` invocation before the frame is captured. This ensures
* the GPU has finished writing to the canvas before the engine screenshots it.
*
* ## Browser feature detection
*
* Always guard against environments where WebGPU is unavailable:
*
* ```js
* if (!navigator.gpu) { /* fallback or early return *\/ }
* const adapter = await navigator.gpu.requestAdapter();
* if (!adapter) { /* GPU unavailable — software fallback *\/ }
* ```
*
* The adapter itself does not check for WebGPU support — that is the
* composition author's responsibility.
*/
export function createTypegpuAdapter(): RuntimeDeterministicAdapter {
let forcedTime: number | null = null;
let lastForcedTime = 0;
return {
name: "typegpu",
discover: () => {
// WebGPU pipelines have no global registry — nothing to auto-discover.
},
seek: (ctx) => {
forcedTime = Math.max(0, Number(ctx.time) || 0);
lastForcedTime = forcedTime;
window.__hfTypegpuTime = forcedTime;
dispatchSeekEvent(forcedTime);
},
pause: () => {
if (forcedTime == null) {
forcedTime = Math.max(0, lastForcedTime);
}
},
play: () => {
forcedTime = null;
},
revert: () => {
forcedTime = null;
lastForcedTime = 0;
},
};
}
@@ -0,0 +1,47 @@
/**
* Patches `GPUQueue.copyExternalImageToTexture` so that video-backed WebGPU
* effects work in both preview and render mode.
*
* During render, the engine's video-frame injector replaces each `<video>`
* with a pre-decoded `<img class="__render_frame__">` sibling. Chrome's
* headless compositor can't supply decoded frames from the native `<video>`
* element to WebGPU, so `copyExternalImageToTexture({ source: video })`
* fails with "Browser fails extracting valid resource from external image."
*
* This patch checks whether a render-frame `<img>` exists next to the
* source `<video>`. If it does and has decoded pixels, the patch
* transparently substitutes it as the copy source. In preview mode (no
* render-frame sibling), the original video path is used unchanged.
*/
export function patchVideoTextureCompat(): void {
const GPUQueueCtor = (globalThis as Record<string, unknown>).GPUQueue as
| { prototype: Record<string, unknown> }
| undefined;
if (!GPUQueueCtor?.prototype?.copyExternalImageToTexture) return;
const orig = GPUQueueCtor.prototype.copyExternalImageToTexture as (
source: unknown,
destination: unknown,
copySize: unknown,
) => void;
GPUQueueCtor.prototype.copyExternalImageToTexture = function (
source: Record<string, unknown>,
destination: unknown,
copySize: unknown,
) {
if (source?.source instanceof HTMLVideoElement) {
const sibling = source.source.nextElementSibling;
if (
sibling instanceof HTMLImageElement &&
sibling.classList.contains("__render_frame__") &&
sibling.complete &&
sibling.naturalWidth > 0
) {
return orig.call(this, { ...source, source: sibling }, destination, copySize);
}
}
return orig.call(this, source, destination, copySize);
};
}
+70 -9
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initSandboxRuntimeModular } from "./init";
import type { RuntimeTimelineLike } from "./types";
@@ -45,6 +45,31 @@ function createPaddableMockTimeline(duration: number): RuntimeTimelineLike {
return timeline;
}
function createManualRaf() {
let now = 0;
let nextId = 0;
const callbacks = new Map<number, FrameRequestCallback>();
return {
requestAnimationFrame: (callback: FrameRequestCallback) => {
nextId += 1;
callbacks.set(nextId, callback);
return nextId;
},
cancelAnimationFrame: (id: number) => {
callbacks.delete(id);
},
step: (milliseconds: number) => {
now += milliseconds;
const pending = Array.from(callbacks.entries());
callbacks.clear();
for (const [, callback] of pending) {
callback(now);
}
},
now: () => now,
};
}
describe("initSandboxRuntimeModular", () => {
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
@@ -67,6 +92,7 @@ describe("initSandboxRuntimeModular", () => {
delete (window as Window & { __player?: unknown }).__player;
delete (window as Window & { __playerReady?: boolean }).__playerReady;
delete (window as Window & { __renderReady?: boolean }).__renderReady;
vi.restoreAllMocks();
window.requestAnimationFrame = originalRequestAnimationFrame;
window.cancelAnimationFrame = originalCancelAnimationFrame;
});
@@ -283,32 +309,67 @@ describe("initSandboxRuntimeModular", () => {
expect(video.currentTime).toBe(0);
});
it("allows external code to reassign delegated __player methods", () => {
it("plays scheduled child timelines without a captured root timeline when audio has failed", () => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "4");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "scene");
child.setAttribute("data-start", "0");
child.setAttribute("data-duration", "4");
root.appendChild(child);
const audio = document.createElement("audio");
audio.setAttribute("data-start", "0");
audio.setAttribute("data-duration", "4");
Object.defineProperty(audio, "error", {
value: { code: 4, message: "format error" },
configurable: true,
});
Object.defineProperty(audio, "networkState", {
value: HTMLMediaElement.NETWORK_NO_SOURCE,
configurable: true,
});
Object.defineProperty(audio, "readyState", {
value: HTMLMediaElement.HAVE_NOTHING,
configurable: true,
});
Object.defineProperty(audio, "paused", { value: true, configurable: true });
Object.defineProperty(audio, "currentTime", { value: 0, writable: true, configurable: true });
audio.load = () => {};
audio.play = vi.fn(() => Promise.reject(new Error("format error")));
root.appendChild(audio);
const childTimeline = createMockTimeline(4);
(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
main: createMockTimeline(10),
scene: childTimeline,
};
initSandboxRuntimeModular();
const player = (
window as Window & {
__player?: { renderSeek: (timeSeconds: number) => void };
__player?: { play: () => void; getTime: () => number; isPlaying: () => boolean };
}
).__player;
expect(player).toBeDefined();
if (!player) return;
const original = player.renderSeek;
expect(() => {
player.renderSeek = (t: number) => original(t);
}).not.toThrow();
player?.play();
raf.step(1_000);
expect(player?.isPlaying()).toBe(true);
expect(player?.getTime()).toBeCloseTo(1, 1);
expect(childTimeline.time()).toBeCloseTo(1, 1);
});
});
+67 -6
View File
@@ -5,6 +5,8 @@ import { createGsapAdapter } from "./adapters/gsap";
import { createAnimeJsAdapter } from "./adapters/animejs";
import { createLottieAdapter } from "./adapters/lottie";
import { createThreeAdapter } from "./adapters/three";
import { createTypegpuAdapter } from "./adapters/typegpu";
import { patchVideoTextureCompat } from "./adapters/video-texture-compat";
import { createWaapiAdapter } from "./adapters/waapi";
import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
import { createPickerModule } from "./picker";
@@ -932,6 +934,15 @@ export function initSandboxRuntimeModular(): void {
if (typeof state.capturedTimeline.timeScale === "function") {
state.capturedTimeline.timeScale(state.playbackRate);
}
const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (boundDuration > 0) {
try {
clock.setDuration(boundDuration);
} catch {
// clock not yet initialized — duration will be set during TransportClock setup
}
state.capturedTimeline.pause();
}
if (resolution.diagnostics) {
postRuntimeMessage({
source: "hf-preview",
@@ -1295,7 +1306,7 @@ export function initSandboxRuntimeModular(): void {
timeSeconds: state.currentTime,
playing: state.isPlaying,
playbackRate: state.playbackRate,
outputMuted: state.mediaOutputMuted,
outputMuted: state.mediaOutputMuted || webAudio.isActive(),
userMuted: state.bridgeMuted,
userVolume: state.bridgeVolume,
forceSync,
@@ -1450,6 +1461,7 @@ export function initSandboxRuntimeModular(): void {
.then(() => loadInlineTemplateCompositions(compositionLoaderParams))
.finally(() => {
externalCompositionsReady = true;
bindRootTimelineIfAvailable();
runAdapters("discover", state.currentTime);
bindMediaMetadataListeners();
installAssetFailureDiagnostics();
@@ -1589,7 +1601,6 @@ export function initSandboxRuntimeModular(): void {
onSetPlaybackRate: (rate) => {
applyPlaybackRate(rate);
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
webAudio.setRate(state.playbackRate);
},
onTick: () => {
if (state.tornDown || !clock.isPlaying()) return;
@@ -1646,8 +1657,10 @@ export function initSandboxRuntimeModular(): void {
createAnimeJsAdapter(),
createLottieAdapter(),
createThreeAdapter(),
createTypegpuAdapter(),
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
] as RuntimeDeterministicAdapter[];
patchVideoTextureCompat();
installRuntimeErrorDiagnostics();
runAdapters("discover");
bindMediaMetadataListeners();
@@ -1666,6 +1679,49 @@ export function initSandboxRuntimeModular(): void {
let transportTickCount = 0;
let inTransportTick = false;
const seekRuntimeTimeline = (
timeline: RuntimeTimelineLike,
timeSeconds: number,
swallowLabel: string,
) => {
try {
timeline.pause();
if (typeof timeline.totalTime === "function") {
timeline.totalTime(timeSeconds, false);
} else {
timeline.seek(timeSeconds, false);
}
} catch (err) {
swallow(swallowLabel, err);
}
};
const seekStandaloneRegisteredTimelines = (timeSeconds: number) => {
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
const rootCompositionId =
resolveRootCompositionElement()?.getAttribute("data-composition-id") ?? null;
for (const [compositionId, timeline] of Object.entries(timelines)) {
if (!timeline || compositionId === rootCompositionId) continue;
const node = document.querySelector(`[data-composition-id="${CSS.escape(compositionId)}"]`);
if (!node) continue;
const start = resolveStartForElement(node, 0);
if (!Number.isFinite(start)) continue;
const authoredDuration = resolveDurationForElement(node, {
includeAuthoredTimingAttrs: true,
});
const timelineDuration = getTimelineDurationSeconds(timeline);
const duration =
authoredDuration != null && authoredDuration > 0 ? authoredDuration : timelineDuration;
const localTime = Math.max(
0,
duration != null && duration > 0
? Math.min(duration, timeSeconds - start)
: timeSeconds - start,
);
seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline");
}
};
const seekTimelineAndAdapters = (t: number) => {
const tl = state.capturedTimeline;
if (tl) {
@@ -1685,6 +1741,8 @@ export function initSandboxRuntimeModular(): void {
// at absolute `t` would clobber their offset-relative position.
// Play/pause propagation for siblings happens in the player.play()
// and player.pause() overrides via the adapter layer.
} else {
seekStandaloneRegisteredTimelines(t);
}
for (const adapter of state.deterministicAdapters) {
try {
@@ -1763,7 +1821,7 @@ export function initSandboxRuntimeModular(): void {
if (!rawEl.paused) {
clock.attachAudioSource({ el: rawEl, compositionStart: start, mediaStart });
foundActive = true;
} else if (rawEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
} else if (!rawEl.error && rawEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
// Audio is buffering — freeze visuals at last known position
// instead of falling through to monotonic (which runs ahead).
clock.attachAudioSource({ currentTimeSeconds: state.currentTime });
@@ -1841,7 +1899,7 @@ export function initSandboxRuntimeModular(): void {
// Player methods route through the TransportClock.
player.play = () => {
const tl = state.capturedTimeline;
if (!tl || clock.isPlaying()) return;
if (clock.isPlaying()) return;
const dur = getSafeTimelineDurationSeconds(tl, 0);
if (dur > 0) {
clock.setDuration(dur);
@@ -1850,8 +1908,12 @@ export function initSandboxRuntimeModular(): void {
state.currentTime = 0;
seekTimelineAndAdapters(0);
}
} else {
const rootEl = resolveRootCompositionElement();
const declaredDur = Number(rootEl?.getAttribute("data-duration") ?? 0);
if (declaredDur > 0) clock.setDuration(declaredDur);
}
tl.pause();
if (tl) tl.pause();
if (!clock.play()) return;
state.isPlaying = true;
state.mediaForceSyncNextTick = true;
@@ -1880,7 +1942,6 @@ export function initSandboxRuntimeModular(): void {
clock.now(),
vol * state.bridgeVolume,
gen,
state.playbackRate,
);
});
}
+16 -2
View File
@@ -198,13 +198,26 @@ export function syncRuntimeMedia(params: {
const offsetJumped = !firstTickOfClip && Math.abs(offset - prevOffset!) > 0.5;
const catastrophicDrift = drift > 3;
const hardSync = drift > 0.5 && (firstTickOfClip || offsetJumped || catastrophicDrift);
// Playing video elements use the browser's native decoder pipeline for
// timing. Seeking a playing video resets the decoder, causing a ~150ms
// freeze while it re-buffers — during which the monotonic clock advances,
// creating a perpetual seek→freeze→drift→seek stutter loop. Skip strict
// and force sync for playing videos; only hard sync (>0.5s) warrants
// the decoder-reset cost.
const isPlayingVideo = el.tagName === "VIDEO" && !el.paused;
// Only apply strict sync when offset has stabilized (not growing).
// During initial buffering, offset grows ~16ms/tick as the timeline
// advances while media stays at 0. Accumulated drift from pause/play
// toggling shows up as a stable, non-zero offset (delta near 0).
const offsetStabilized = prevOffset !== undefined && Math.abs(offset - prevOffset) < 0.004;
let strictSync = false;
if (!hardSync && !firstTickOfClip && offsetStabilized && drift > STRICT_DRIFT_THRESHOLD) {
if (
!isPlayingVideo &&
!hardSync &&
!firstTickOfClip &&
offsetStabilized &&
drift > STRICT_DRIFT_THRESHOLD
) {
const samples = (strictDriftSamples.get(el) ?? 0) + 1;
strictDriftSamples.set(el, samples);
if (samples >= STRICT_REQUIRED_SAMPLES) {
@@ -214,7 +227,8 @@ export function syncRuntimeMedia(params: {
} else if (drift <= STRICT_DRIFT_THRESHOLD) {
strictDriftSamples.set(el, 0);
}
if (hardSync || strictSync || (params.forceSync && drift > 0.02)) {
const forceSync = !isPlayingVideo && params.forceSync && drift > 0.02;
if (hardSync || strictSync || forceSync) {
try {
el.currentTime = relTime;
} catch (err) {
@@ -0,0 +1,358 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { createMediaPreloadManager } from "./mediaPreloader";
function mockMediaElement(attrs: {
start: string;
duration?: string;
tag?: string;
}): HTMLMediaElement {
const el = {
tagName: (attrs.tag ?? "VIDEO").toUpperCase(),
preload: "auto",
readyState: 0,
duration: Number.NaN,
defaultPlaybackRate: 1,
loop: false,
src: `blob:mock-${attrs.start}`,
dataset: {
start: attrs.start,
duration: attrs.duration,
},
hasAttribute: (name: string) => name === "data-start",
getAttribute: (name: string) => {
if (name === "data-start") return attrs.start;
if (name === "data-duration") return attrs.duration ?? null;
return null;
},
removeAttribute: (name: string) => {
if (name === "src") {
(el as Record<string, unknown>).src = "";
}
},
closest: () => null,
load: vi.fn(),
} as unknown as HTMLMediaElement;
return el;
}
function setupDOM(elements: HTMLMediaElement[]): void {
const originalQuerySelector = document.querySelectorAll.bind(document);
document.querySelectorAll = ((selector: string) => {
if (selector === "video, audio") return elements as unknown as NodeListOf<Element>;
return originalQuerySelector(selector);
}) as typeof document.querySelectorAll;
}
describe("createMediaPreloadManager", () => {
let elements: HTMLMediaElement[];
beforeEach(() => {
elements = [];
});
it("is not lazy when fewer than 6 media elements", () => {
elements = [
mockMediaElement({ start: "0", duration: "5" }),
mockMediaElement({ start: "5", duration: "5" }),
];
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
expect(manager.isLazy()).toBe(false);
});
it("activates lazy mode at exactly LAZY_THRESHOLD (6 elements)", () => {
elements = Array.from({ length: 6 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
expect(manager.isLazy()).toBe(true);
});
it("is not lazy with 5 elements (below threshold)", () => {
elements = Array.from({ length: 5 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
expect(manager.isLazy()).toBe(false);
});
it("activates lazy mode with 8 media elements", () => {
elements = Array.from({ length: 8 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
expect(manager.isLazy()).toBe(true);
});
it("sync promotes clips in the lookahead window", () => {
elements = Array.from({ length: 8 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
for (const el of elements) {
el.preload = "metadata";
}
manager.sync(0);
expect(elements[0].preload).toBe("auto");
expect(elements[1].preload).toBe("auto");
expect(elements[7].preload).toBe("metadata");
});
it("preloadAroundTime promotes clips near seek target", () => {
elements = Array.from({ length: 10 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
for (const el of elements) {
el.preload = "metadata";
}
manager.preloadAroundTime(30);
expect(elements[6].preload).toBe("auto");
expect(elements[7].preload).toBe("auto");
expect(elements[0].preload).toBe("metadata");
});
it("sync is a no-op when not lazy", () => {
elements = [
mockMediaElement({ start: "0", duration: "5" }),
mockMediaElement({ start: "5", duration: "5" }),
];
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
manager.sync(0);
expect(manager.isLazy()).toBe(false);
});
it("guarantees at least LOOKAHEAD_MIN_CLIPS are promoted", () => {
elements = Array.from({ length: 8 }, (_, i) =>
mockMediaElement({ start: String(i * 20), duration: "5" }),
);
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
for (const el of elements) {
el.preload = "metadata";
}
manager.sync(0);
const promotedCount = elements.filter((el) => el.preload === "auto").length;
expect(promotedCount).toBeGreaterThanOrEqual(2);
});
it("evicts clips when scrubbing away from them", () => {
elements = Array.from({ length: 10 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
for (const el of elements) {
el.preload = "metadata";
}
// Promote clips around t=0
manager.sync(0);
expect(elements[0].preload).toBe("auto");
expect(elements[1].preload).toBe("auto");
// Scrub to t=40 — clips 0,1 should be evicted
manager.sync(40);
expect(elements[0].preload).toBe("metadata");
expect(elements[0].src).toBe("");
expect(elements[8].preload).toBe("auto");
});
it("restores src when re-promoting a previously evicted clip", () => {
elements = Array.from({ length: 10 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
for (const el of elements) {
el.preload = "metadata";
}
const originalSrc0 = elements[0].src;
// Promote at t=0, scrub away, scrub back
manager.sync(0);
manager.sync(40);
expect(elements[0].src).toBe("");
manager.sync(0);
expect(elements[0].src).toBe(originalSrc0);
expect(elements[0].preload).toBe("auto");
});
it("does not exceed MAX_PROMOTED (5) clips", () => {
// 10 clips, each 5s long, spaced 5s apart
elements = Array.from({ length: 10 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
for (const el of elements) {
el.preload = "metadata";
}
// Sync at t=0 — window covers clips 0,1,2 (0-15s lookahead)
manager.sync(0);
const promotedAfterFirst = elements.filter((el) => el.preload === "auto").length;
expect(promotedAfterFirst).toBeLessThanOrEqual(5);
// Sync at different position — should evict old ones
manager.sync(25);
const totalPromoted = elements.filter((el) => el.preload === "auto").length;
expect(totalPromoted).toBeLessThanOrEqual(5);
});
it("calls load() when evicting to release buffers", () => {
elements = Array.from({ length: 10 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
for (const el of elements) {
el.preload = "metadata";
}
manager.sync(0);
const loadCallsBefore = (elements[0].load as ReturnType<typeof vi.fn>).mock.calls.length;
// Scrub away — eviction should call load() to release buffers
manager.sync(40);
const loadCallsAfter = (elements[0].load as ReturnType<typeof vi.fn>).mock.calls.length;
expect(loadCallsAfter).toBeGreaterThan(loadCallsBefore);
});
it("isLazy reports true with 6+ clips so caller can gate render-mode bypass", () => {
elements = Array.from({ length: 6 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
expect(manager.isLazy()).toBe(true);
});
it("calls onActivation when lazy mode activates", () => {
elements = Array.from({ length: 8 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
const onActivation = vi.fn();
const manager = createMediaPreloadManager({ onActivation });
manager.refresh();
expect(onActivation).toHaveBeenCalledOnce();
expect(onActivation).toHaveBeenCalledWith(8);
});
it("does not call onActivation below threshold", () => {
elements = [
mockMediaElement({ start: "0", duration: "5" }),
mockMediaElement({ start: "5", duration: "5" }),
];
setupDOM(elements);
const onActivation = vi.fn();
const manager = createMediaPreloadManager({ onActivation });
manager.refresh();
expect(onActivation).not.toHaveBeenCalled();
});
it("calls onActivation only once across multiple refreshes", () => {
elements = Array.from({ length: 8 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
const onActivation = vi.fn();
const manager = createMediaPreloadManager({ onActivation });
manager.refresh();
manager.refresh();
manager.refresh();
expect(onActivation).toHaveBeenCalledOnce();
});
it("respects window.__HF_LAZY_PRELOAD_THRESHOLD override", () => {
elements = Array.from({ length: 4 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
// 4 elements is below the default threshold (6) but at our custom one
(window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD = 4;
const manager = createMediaPreloadManager();
manager.refresh();
expect(manager.isLazy()).toBe(true);
// Clean up
delete (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD;
});
it("falls back to default threshold when __HF_LAZY_PRELOAD_THRESHOLD is not set", () => {
elements = Array.from({ length: 4 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
// Ensure it's not set
delete (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD;
const manager = createMediaPreloadManager();
manager.refresh();
expect(manager.isLazy()).toBe(false);
});
});
+167
View File
@@ -0,0 +1,167 @@
import { refreshRuntimeMediaCache, type RuntimeMediaClip } from "./media";
// Compositions with fewer than 6 timed clips rarely exceed browser memory
// limits during eager preload. The threshold avoids preload management
// overhead for typical compositions while catching the heavy-media case
// (e.g., 20 clips / 6GB reported in heygen-com/hyperframes#729).
const LAZY_THRESHOLD = 6;
const LOOKAHEAD_SECONDS = 10;
const LOOKAHEAD_MIN_CLIPS = 2;
// Cap on simultaneously promoted (buffered) clips. When the lookahead window
// contains more clips than this (e.g., many short clips), all window clips
// stay promoted — the cap is defense-in-depth, not a hard ceiling. The primary
// memory bound comes from window-based eviction in syncWindow().
const MAX_PROMOTED = 5;
export interface MediaPreloadManager {
refresh(): void;
sync(currentTimeSeconds: number): void;
preloadAroundTime(timeSeconds: number): void;
isLazy(): boolean;
}
export function createMediaPreloadManager(options?: {
resolveStartSeconds?: (element: Element) => number;
resolveDurationSeconds?: (element: HTMLVideoElement | HTMLAudioElement) => number | null;
shouldIncludeElement?: (element: HTMLVideoElement | HTMLAudioElement) => boolean;
onActivation?: (clipCount: number) => void;
}): MediaPreloadManager {
let clips: RuntimeMediaClip[] = [];
const promoted = new Set<HTMLMediaElement>();
/** Insertion-order queue for LRU eviction (oldest first). */
const promotionOrder: HTMLMediaElement[] = [];
/** Stashed original src so we can restore after eviction. */
const originalSrc = new Map<HTMLMediaElement, string>();
let lazy = false;
let activationEmitted = false;
function refresh(): void {
const cache = refreshRuntimeMediaCache(options);
clips = cache.mediaClips;
const configuredThreshold =
typeof (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD === "number"
? ((window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD as number)
: LAZY_THRESHOLD;
lazy = clips.length >= configuredThreshold;
if (lazy && !activationEmitted) {
activationEmitted = true;
options?.onActivation?.(clips.length);
}
}
function evictClip(clip: RuntimeMediaClip): void {
if (!promoted.has(clip.el)) return;
// Stash original src before clearing
if (!originalSrc.has(clip.el)) {
originalSrc.set(clip.el, clip.el.src);
}
// Release buffered data: only way to free memory per MDN
clip.el.removeAttribute("src");
clip.el.load();
clip.el.preload = "metadata";
promoted.delete(clip.el);
const idx = promotionOrder.indexOf(clip.el);
if (idx !== -1) promotionOrder.splice(idx, 1);
}
function promoteClip(clip: RuntimeMediaClip): void {
if (promoted.has(clip.el)) return;
// Restore src if previously evicted
const stashedSrc = originalSrc.get(clip.el);
if (stashedSrc !== undefined && !clip.el.src) {
clip.el.src = stashedSrc;
originalSrc.delete(clip.el);
}
promoted.add(clip.el);
promotionOrder.push(clip.el);
if (clip.el.preload !== "auto") {
clip.el.preload = "auto";
}
if (clip.el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
clip.el.load();
}
}
function evictOutsideWindow(inWindow: Set<RuntimeMediaClip>): void {
const windowEls = new Set<HTMLMediaElement>();
for (const clip of inWindow) {
windowEls.add(clip.el);
}
// Evict clips no longer in window, oldest first
for (const clip of clips) {
if (promoted.has(clip.el) && !windowEls.has(clip.el)) {
evictClip(clip);
}
}
// If still over budget after removing out-of-window clips,
// evict the oldest promoted that isn't in the current window
while (promotionOrder.length > MAX_PROMOTED) {
const oldest = promotionOrder[0];
if (windowEls.has(oldest)) break; // don't evict something currently needed
const clip = clips.find((c) => c.el === oldest);
if (clip) {
evictClip(clip);
} else {
// Element no longer in clips list, just remove from tracking
promoted.delete(oldest);
promotionOrder.shift();
}
}
}
function getClipsInWindow(timeSeconds: number): Set<RuntimeMediaClip> {
const windowEnd = timeSeconds + LOOKAHEAD_SECONDS;
const inWindow = new Set<RuntimeMediaClip>();
for (const clip of clips) {
const active = timeSeconds >= clip.start && timeSeconds < clip.end;
const inLookahead = clip.start >= timeSeconds && clip.start <= windowEnd;
if (active || inLookahead) {
inWindow.add(clip);
}
}
if (inWindow.size < LOOKAHEAD_MIN_CLIPS) {
const sorted = clips
.filter((c) => c.start >= timeSeconds && !inWindow.has(c))
.sort((a, b) => a.start - b.start);
for (const clip of sorted) {
inWindow.add(clip);
if (inWindow.size >= LOOKAHEAD_MIN_CLIPS) break;
}
}
return inWindow;
}
function syncWindow(timeSeconds: number): void {
const window = getClipsInWindow(timeSeconds);
evictOutsideWindow(window);
for (const clip of clips) {
if (window.has(clip)) {
promoteClip(clip);
}
}
}
function sync(currentTimeSeconds: number): void {
if (!lazy) return;
syncWindow(currentTimeSeconds);
}
function preloadAroundTime(timeSeconds: number): void {
if (!lazy) return;
syncWindow(timeSeconds);
}
function isLazy(): boolean {
return lazy;
}
return { refresh, sync, preloadAroundTime, isLazy };
}
+16
View File
@@ -58,12 +58,28 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
});
}
function isEffectivelyHidden(el: HTMLElement): boolean {
const win = el.ownerDocument.defaultView;
if (!win) return false;
let current: HTMLElement | null = el;
while (current && current !== document.body && current !== document.documentElement) {
const computed = win.getComputedStyle(current);
if (computed.display === "none" || computed.visibility === "hidden") return true;
if (computed.pointerEvents === "none") return true;
const opacity = Number.parseFloat(computed.opacity);
if (Number.isFinite(opacity) && opacity <= 0.01) return true;
current = current.parentElement;
}
return false;
}
function isPickableElement(el: Element | null): el is Element {
if (!el || el === document.body || el === document.documentElement) return false;
const tag = el.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") return false;
if (el.classList.contains("__hf-pick-highlight")) return false;
if (el.closest(PICKER_IGNORE_SELECTOR)) return false;
if (isEffectivelyHidden(el as HTMLElement)) return false;
return true;
}
+9
View File
@@ -35,6 +35,15 @@ declare global {
__HF_FPS?: number;
__HF_MAX_DURATION_SEC?: number;
__hfThreeTime?: number;
/**
* Current seek position in seconds, set by the TypeGPU/WebGPU adapter.
* Poll this from your WebGPU render loop instead of `performance.now()`
* to get the deterministic seek position.
*
* Also listen for the `"hf-seek"` CustomEvent on `window` for an
* imperative push signal: `window.addEventListener("hf-seek", e => render(e.detail.time))`.
*/
__hfTypegpuTime?: number;
__HF_PICKER_API?: HyperframePickerApi;
gsap?: {
timeline: (params?: { paused?: boolean }) => RuntimeTimelineLike;
@@ -7,6 +7,7 @@ import { registerLintRoutes } from "./routes/lint.js";
import { registerRenderRoutes } from "./routes/render.js";
import { registerThumbnailRoutes } from "./routes/thumbnail.js";
import { registerWaveformRoutes } from "./routes/waveform.js";
import { registerFontRoutes } from "./routes/fonts.js";
/**
* Create a Hono sub-app with all studio API routes.
@@ -24,6 +25,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono {
registerRenderRoutes(api, adapter);
registerThumbnailRoutes(api, adapter);
registerWaveformRoutes(api, adapter);
registerFontRoutes(api);
return api;
}
@@ -0,0 +1,382 @@
import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
import { createStudioManualEditsRenderBodyScript } from "./manualEditsRenderScript";
function runScript(
window: Window,
script: string,
getComputedStyle: typeof window.getComputedStyle = window.getComputedStyle.bind(window),
timers: {
setInterval?: typeof globalThis.setInterval;
clearInterval?: typeof globalThis.clearInterval;
} = {},
): void {
const execute = new Function(
"window",
"document",
"HTMLElement",
"getComputedStyle",
"setInterval",
"clearInterval",
script,
);
execute(
window,
window.document,
window.HTMLElement,
getComputedStyle,
timers.setInterval ??
(((callback: TimerHandler) => {
void callback;
return 0 as never;
}) as typeof globalThis.setInterval),
timers.clearInterval ?? globalThis.clearInterval,
);
}
describe("createStudioManualEditsRenderBodyScript", () => {
it("returns null for an empty manifest", () => {
expect(createStudioManualEditsRenderBodyScript("")).toBeNull();
});
it("applies manual edits and reapplies them after render seeks", () => {
const window = new Window();
window.document.body.innerHTML = '<div id="card" style="width: 20px; height: 20px"></div>';
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
let seekCalls = 0;
(
window as unknown as {
__hf: { seek: (time: number) => void };
}
).__hf = {
seek: () => {
seekCalls += 1;
card.style.removeProperty("translate");
},
};
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "index.html", id: "card" },
x: 12,
y: 24,
},
{
kind: "box-size",
target: { sourceFile: "index.html", id: "card" },
width: 120,
height: 64,
},
{
kind: "rotation",
target: { sourceFile: "index.html", id: "card" },
angle: 15,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
const computedStyle = (element: Element) =>
({
display: element === card ? "block" : "block",
flexDirection: "row",
}) as CSSStyleDeclaration;
const intervalCallbacks: Array<() => void> = [];
runScript(window, script, computedStyle, {
setInterval: ((callback: TimerHandler) => {
if (typeof callback === "function") intervalCallbacks.push(callback as () => void);
return 0 as never;
}) as typeof globalThis.setInterval,
});
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
expect(card.style.getPropertyValue("width")).toBe("120px");
expect(card.style.getPropertyValue("height")).toBe("64px");
expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
(
window as unknown as {
__hf: { seek: (time: number) => void };
}
).__hf.seek(1);
expect(seekCalls).toBe(1);
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
(
window as unknown as {
__hf: { seek: (time: number) => void };
}
).__hf.seek = () => {
card.style.removeProperty("rotate");
};
intervalCallbacks.forEach((callback) => callback());
(
window as unknown as {
__hf: { seek: (time: number) => void };
}
).__hf.seek(2);
expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
(
window as unknown as {
__player: { renderSeek: (time: number) => void };
}
).__player = {
renderSeek: () => {
card.style.removeProperty("rotate");
},
};
intervalCallbacks.forEach((callback) => callback());
(
window as unknown as {
__player: { renderSeek: (time: number) => void };
}
).__player.renderSeek(3);
expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
});
it("applies render edits to the matching source file target", () => {
const window = new Window();
window.document.body.innerHTML = `
<div data-composition-id="root">
<div id="card"></div>
<div data-composition-id="nested" data-composition-file="scenes/nested.html">
<div id="card"></div>
</div>
</div>
`;
const cards = Array.from(window.document.getElementsByTagName("*")).filter(
(element): element is HTMLElement =>
element instanceof window.HTMLElement && element.id === "card",
);
const rootCard = cards[0];
const nestedCard = cards[1];
if (!rootCard || !nestedCard) {
throw new Error("source-scoped render fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "rotation",
target: { sourceFile: "scenes/nested.html", id: "card" },
angle: 21,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(rootCard.style.getPropertyValue("rotate")).toBe("");
expect(nestedCard.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
});
it("applies render edits inside composition-file hosts without composition ids", () => {
const window = new Window();
window.document.body.innerHTML = `
<div data-composition-id="root">
<div id="card"></div>
<div data-composition-file="scenes/anonymous.html">
<div id="card"></div>
</div>
</div>
`;
const cards = Array.from(window.document.getElementsByTagName("*")).filter(
(element): element is HTMLElement =>
element instanceof window.HTMLElement && element.id === "card",
);
const rootCard = cards[0];
const nestedCard = cards[1];
if (!rootCard || !nestedCard) {
throw new Error("anonymous composition render fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "scenes/anonymous.html", id: "card" },
x: 12,
y: 24,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(rootCard.style.getPropertyValue("translate")).toBe("");
expect(nestedCard.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
});
it("uses the active composition path as the unscoped document fallback", () => {
const window = new Window();
window.document.body.innerHTML = `<div id="card"></div>`;
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "compositions/scene-2.html", id: "card" },
x: 12,
y: 24,
},
],
}),
{ activeCompositionPath: "compositions/scene-2.html" },
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
});
it("preserves computed transform longhands as render edit bases", () => {
const window = new Window();
window.document.body.innerHTML = `<div id="card"></div>`;
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "index.html", id: "card" },
x: 12,
y: 24,
},
{
kind: "rotation",
target: { sourceFile: "index.html", id: "card" },
angle: 15,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
const computedStyle = (element: Element) =>
({
getPropertyValue: (property: string) => {
if (element !== card) return "";
if (property === "translate") return "10px 20px";
if (property === "rotate") return "8deg";
return "";
},
}) as CSSStyleDeclaration;
runScript(window, script, computedStyle);
expect(card.style.getPropertyValue("translate")).toContain("calc(10px +");
expect(card.style.getPropertyValue("translate")).toContain("calc(20px +");
expect(card.style.getPropertyValue("rotate")).toContain("8deg");
expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
});
it("does not compound stale studio variables during render reapply", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card" style="
translate: var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px);
rotate: var(--hf-studio-rotation, 0deg);
"></div>
`;
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "index.html", id: "card" },
x: 12,
y: 24,
},
{
kind: "rotation",
target: { sourceFile: "index.html", id: "card" },
angle: 15,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(card.style.getPropertyValue("translate")).toBe(
"var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)",
);
expect(card.style.getPropertyValue("rotate")).toBe("var(--hf-studio-rotation, 0deg)");
});
it("exposes a render reapply hook for thumbnails after layout settles", () => {
const window = new Window();
window.document.body.innerHTML = `<div id="card"></div>`;
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "index.html", id: "card" },
x: 12,
y: 24,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
card.style.removeProperty("translate");
(
window as unknown as {
__hfStudioManualEditsApply?: () => number;
}
).__hfStudioManualEditsApply?.();
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
});
});
@@ -0,0 +1,371 @@
export interface StudioManualEditsRenderScriptOptions {
activeCompositionPath?: string | null;
}
export const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
export function createStudioManualEditsRenderBodyScript(
manifestContent: string,
options: StudioManualEditsRenderScriptOptions = {},
): string | null {
if (!manifestContent.trim()) return null;
return `(${studioManualEditsRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`;
}
function studioManualEditsRenderRuntime(
manifestContent: string,
activeCompositionPath: string | null,
): void {
const OFFSET_X_PROP = "--hf-studio-offset-x";
const OFFSET_Y_PROP = "--hf-studio-offset-y";
const WIDTH_PROP = "--hf-studio-width";
const HEIGHT_PROP = "--hf-studio-height";
const ROTATION_PROP = "--hf-studio-rotation";
const PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
const BOX_SIZE_ATTR = "data-hf-studio-box-size";
const ROTATION_ATTR = "data-hf-studio-rotation";
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
const WRAPPED_SEEK_PROP = "__hfStudioManualEditsWrapped";
const ROTATION_TRANSFORM_ORIGIN = "center center";
const finiteNumber = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
const objectRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === "object" ? (value as Record<string, unknown>) : null;
const runtimeWindow = window as Window & {
__hf?: { seek?: (time: number) => unknown };
__hfStudioManualEditsApply?: () => number;
__player?: { renderSeek?: (time: number) => unknown };
};
const parsedManifest = (() => {
try {
return objectRecord(JSON.parse(manifestContent));
} catch {
return null;
}
})();
const manifestEdits = Array.isArray(parsedManifest?.edits) ? parsedManifest.edits : [];
if (manifestEdits.length === 0) return;
const sourceFileForElement = (element: HTMLElement): string => {
let current: HTMLElement | null = element;
while (current) {
const sourceFile =
current.getAttribute("data-composition-file") ??
current.getAttribute("data-composition-src");
if (sourceFile) return sourceFile;
current = current.parentElement;
}
return activeCompositionPath ?? "index.html";
};
const elementMatchesSourceFile = (element: HTMLElement, sourceFile: string): boolean =>
sourceFileForElement(element) === sourceFile;
const styleUsesStudioOffset = (value: string): boolean =>
value.includes(OFFSET_X_PROP) || value.includes(OFFSET_Y_PROP);
const styleUsesStudioRotation = (value: string): boolean => value.includes(ROTATION_PROP);
const splitTopLevelWhitespace = (value: string): string[] => {
const parts: string[] = [];
let depth = 0;
let current = "";
for (const char of value.trim()) {
if (char === "(") depth += 1;
if (char === ")") depth = Math.max(0, depth - 1);
if (/\s/.test(char) && depth === 0) {
if (current) parts.push(current);
current = "";
} else {
current += char;
}
}
if (current) parts.push(current);
return parts;
};
const composeTranslate = (element: HTMLElement, x: string, y: string): string => {
const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim();
if (!original || original === "none") return `${x} ${y}`;
const parts = splitTopLevelWhitespace(original);
if (parts.length === 1) return `calc(${parts[0]} + ${x}) ${y}`;
if (parts.length === 2) return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y})`;
if (parts.length === 3) {
return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y}) ${parts[2]}`;
}
return `${x} ${y}`;
};
const readStyleOrComputed = (element: HTMLElement, property: string): string => {
try {
return (
element.style.getPropertyValue(property) ||
getComputedStyle(element).getPropertyValue(property)
);
} catch {
return element.style.getPropertyValue(property);
}
};
const readTransformLonghandBase = (
element: HTMLElement,
property: "translate" | "rotate",
): string => {
const value = readStyleOrComputed(element, property).trim();
return value === "none" ? "" : value;
};
const preparePathOffsetBase = (element: HTMLElement): void => {
const currentTranslate = readTransformLonghandBase(element, "translate");
const hasMarker = element.hasAttribute(PATH_OFFSET_ATTR);
const wasResetByAnimation = !styleUsesStudioOffset(currentTranslate);
if (!hasMarker) {
element.setAttribute(ORIGINAL_TRANSLATE_ATTR, wasResetByAnimation ? currentTranslate : "");
} else if (wasResetByAnimation) {
element.setAttribute(ORIGINAL_TRANSLATE_ATTR, currentTranslate);
}
};
const prepareRotationBase = (element: HTMLElement): void => {
const currentRotate = readTransformLonghandBase(element, "rotate");
const hasMarker = element.hasAttribute(ROTATION_ATTR);
const wasResetByAnimation = !styleUsesStudioRotation(currentRotate);
if (!hasMarker) {
element.setAttribute(ORIGINAL_ROTATE_ATTR, wasResetByAnimation ? currentRotate : "");
} else if (wasResetByAnimation) {
element.setAttribute(ORIGINAL_ROTATE_ATTR, currentRotate);
}
};
const querySelectorCandidates = (selector: string): HTMLElement[] => {
const isCandidate = (element: Element): element is HTMLElement =>
element instanceof HTMLElement;
const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1];
if (className) {
return Array.from(document.getElementsByTagName("*")).filter(
(element): element is HTMLElement =>
isCandidate(element) && element.classList.contains(className),
);
}
if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) {
return Array.from(document.getElementsByTagName(selector)).filter(isCandidate);
}
return Array.from(document.querySelectorAll(selector)).filter(isCandidate);
};
const resolveTarget = (edit: Record<string, unknown>): HTMLElement | null => {
const targetRecord = objectRecord(edit.target);
if (!targetRecord) return null;
const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : "";
if (!sourceFile) return null;
const id = typeof targetRecord.id === "string" ? targetRecord.id : "";
if (id) {
const byId = document.getElementById(id);
if (byId instanceof HTMLElement && elementMatchesSourceFile(byId, sourceFile)) return byId;
const matchesById = [
document.documentElement,
...Array.from(document.getElementsByTagName("*")),
].filter(
(element): element is HTMLElement =>
element instanceof HTMLElement &&
element.id === id &&
elementMatchesSourceFile(element, sourceFile),
);
if (matchesById[0]) return matchesById[0];
}
const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : "";
if (!selector) return null;
try {
const matches = querySelectorCandidates(selector).filter((element) =>
elementMatchesSourceFile(element, sourceFile),
);
const selectorIndex = finiteNumber(targetRecord.selectorIndex) ?? 0;
return matches[Math.max(0, Math.floor(selectorIndex))] ?? null;
} catch {
return null;
}
};
const roundRotationAngle = (angle: number): number => Math.round(angle * 10) / 10;
const isSimpleRotateAngle = (value: string): boolean =>
/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim());
const composeRotation = (element: HTMLElement, rotationValue: string): string => {
const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim();
if (!original || original === "none" || !isSimpleRotateAngle(original)) {
return rotationValue;
}
return `calc(${original} + ${rotationValue})`;
};
const applyPathOffset = (element: HTMLElement, edit: Record<string, unknown>): void => {
const x = finiteNumber(edit.x);
const y = finiteNumber(edit.y);
if (x == null || y == null) return;
preparePathOffsetBase(element);
element.setAttribute(PATH_OFFSET_ATTR, "true");
element.style.setProperty(OFFSET_X_PROP, `${Math.round(x)}px`);
element.style.setProperty(OFFSET_Y_PROP, `${Math.round(y)}px`);
element.style.setProperty(
"translate",
composeTranslate(element, `var(${OFFSET_X_PROP}, 0px)`, `var(${OFFSET_Y_PROP}, 0px)`),
);
};
const readParentFlexBasisPixels = (
element: HTMLElement,
size: { width: number; height: number },
): number | null => {
const parent = element.parentElement;
if (!parent) return null;
const styles = getComputedStyle(parent);
if (styles.display !== "flex" && styles.display !== "inline-flex") return null;
return Math.round(
Math.max(1, styles.flexDirection.startsWith("column") ? size.height : size.width),
);
};
const applyBoxSize = (element: HTMLElement, edit: Record<string, unknown>): void => {
const width = finiteNumber(edit.width);
const height = finiteNumber(edit.height);
if (width == null || height == null || width <= 0 || height <= 0) return;
const rounded = {
width: Math.round(Math.max(1, width)),
height: Math.round(Math.max(1, height)),
};
element.setAttribute(BOX_SIZE_ATTR, "true");
element.style.setProperty(WIDTH_PROP, `${rounded.width}px`);
element.style.setProperty(HEIGHT_PROP, `${rounded.height}px`);
element.style.setProperty("box-sizing", "border-box");
element.style.setProperty("width", `${rounded.width}px`);
element.style.setProperty("height", `${rounded.height}px`);
element.style.setProperty("min-width", "0px");
element.style.setProperty("min-height", "0px");
element.style.setProperty("max-width", "none");
element.style.setProperty("max-height", "none");
const flexBasis = readParentFlexBasisPixels(element, rounded);
if (flexBasis != null) {
element.style.setProperty("flex-basis", `${flexBasis}px`);
element.style.setProperty("flex-grow", "0");
element.style.setProperty("flex-shrink", "0");
}
if (getComputedStyle(element).display === "inline") {
element.style.setProperty("display", "inline-block");
}
};
const applyRotation = (element: HTMLElement, edit: Record<string, unknown>): void => {
const angle = finiteNumber(edit.angle);
if (angle == null) return;
prepareRotationBase(element);
element.setAttribute(ROTATION_ATTR, "true");
element.style.setProperty(ROTATION_PROP, `${roundRotationAngle(angle)}deg`);
element.style.setProperty("transform-origin", ROTATION_TRANSFORM_ORIGIN);
element.style.setProperty("rotate", composeRotation(element, `var(${ROTATION_PROP}, 0deg)`));
};
const applyManifest = (): number => {
let applied = 0;
for (const edit of manifestEdits) {
const editRecord = objectRecord(edit);
if (!editRecord) continue;
const element = resolveTarget(editRecord);
if (!element) continue;
if (editRecord.kind === "path-offset") applyPathOffset(element, editRecord);
if (editRecord.kind === "box-size") applyBoxSize(element, editRecord);
if (editRecord.kind === "rotation") applyRotation(element, editRecord);
applied += 1;
}
return applied;
};
runtimeWindow.__hfStudioManualEditsApply = applyManifest;
const markWrapped = (fn: (time: number) => unknown): void => {
try {
Object.defineProperty(fn, WRAPPED_SEEK_PROP, {
configurable: false,
enumerable: false,
value: true,
});
} catch {
try {
(fn as unknown as Record<string, unknown>)[WRAPPED_SEEK_PROP] = true;
} catch {
// Ignore non-extensible functions.
}
}
};
const isWrapped = (fn: (time: number) => unknown): boolean =>
Boolean((fn as unknown as Record<string, unknown>)[WRAPPED_SEEK_PROP]);
const wrapFunction = (
get: () => ((time: number) => unknown) | undefined,
set: (fn: (time: number) => unknown) => void,
): boolean => {
const fn = get();
if (!fn) return false;
const seek = fn as (time: number) => unknown;
if (isWrapped(seek)) {
applyManifest();
return true;
}
const wrappedSeek = function (this: unknown, time: number): unknown {
const result = seek.call(this, time);
applyManifest();
return result;
};
markWrapped(wrappedSeek);
set(wrappedSeek);
applyManifest();
return true;
};
const wrapSeekFunctions = (): boolean => {
const wrappedHfSeek = wrapFunction(
() => runtimeWindow.__hf?.seek,
(fn) => {
if (runtimeWindow.__hf) runtimeWindow.__hf.seek = fn;
},
);
const wrappedPlayerRenderSeek = wrapFunction(
() => runtimeWindow.__player?.renderSeek,
(fn) => {
if (runtimeWindow.__player) runtimeWindow.__player.renderSeek = fn;
},
);
return wrappedHfSeek || wrappedPlayerRenderSeek;
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => applyManifest(), { once: true });
} else {
applyManifest();
}
wrapSeekFunctions();
let remainingSeekWrapAttempts = 120;
const seekWrapInterval = setInterval(() => {
wrapSeekFunctions();
remainingSeekWrapAttempts -= 1;
if (remainingSeekWrapAttempts <= 0) clearInterval(seekWrapInterval);
}, 50);
}
@@ -28,6 +28,10 @@ const SIGNATURE_EXCLUDED_DIRS = new Set([
"renders",
]);
const MAX_SIGNATURE_TEXT_BYTES = 2_000_000;
const STUDIO_SIGNATURE_MANIFEST_PATHS = [
".hyperframes/studio-manual-edits.json",
".hyperframes/studio-motion.json",
] as const;
interface ProjectSignatureFile {
file: string;
@@ -93,6 +97,31 @@ function collectProjectSignatureFiles(
}
}
function collectProjectSignatureManifestFiles(
projectDir: string,
files: ProjectSignatureFile[],
): void {
const seen = new Set(files.map((entry) => entry.file));
for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
const file = resolve(projectDir, manifestPath);
if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
let stat: ReturnType<typeof lstatSync>;
try {
stat = lstatSync(file);
} catch {
continue;
}
if (stat.isSymbolicLink() || !stat.isFile()) continue;
files.push({
file,
mtimeMs: stat.mtimeMs,
size: stat.size,
textContentEligible: isTextContentEligible(file, stat.size),
});
seen.add(file);
}
}
function createProjectFingerprint(projectDir: string, files: ProjectSignatureFile[]): string {
const hash = createHash("sha256");
for (const entry of files) {
@@ -108,10 +137,14 @@ function createProjectFingerprint(projectDir: string, files: ProjectSignatureFil
return hash.digest("hex").slice(0, 24);
}
/**
* Creates a stable preview cache-busting signature for project source plus Studio manifests.
*/
export function createProjectSignature(projectDir: string): string {
const normalizedProjectDir = resolve(projectDir);
const files: ProjectSignatureFile[] = [];
collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, files);
collectProjectSignatureManifestFiles(normalizedProjectDir, files);
files.sort((a, b) => a.file.localeCompare(b.file));
const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
@@ -5,8 +5,15 @@ export interface ScreenshotClip {
height: number;
}
export function getElementScreenshotClip(selector: string): ScreenshotClip | undefined {
const el = document.querySelector(selector);
export function getElementScreenshotClip(
selector: string,
selectorIndex?: number,
): ScreenshotClip | undefined {
const matches = Array.from(document.querySelectorAll(selector)).filter(
(el): el is HTMLElement => el instanceof HTMLElement,
);
const safeIndex = Math.max(0, Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0)));
const el = matches[safeIndex] ?? null;
if (!(el instanceof HTMLElement)) return undefined;
const rect = el.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) return undefined;
@@ -17,6 +17,20 @@ function parseSourceDocument(source: string): { document: Document; wrappedFragm
};
}
function querySelectorAllWithTemplates(root: Document | Element, selector: string): Element[] {
const matches = Array.from(root.querySelectorAll(selector));
if (matches.length > 0) return matches;
// querySelectorAll doesn't traverse <template> content in linkedom.
// Search directly on each template element (NOT .content — removing from
// .content's DocumentFragment doesn't update the serialized output).
const templates = Array.from(root.querySelectorAll("template"));
for (const tmpl of templates) {
const inner = tmpl.querySelectorAll(selector);
if (inner.length > 0) return Array.from(inner);
}
return [];
}
function findTargetElement(document: Document, target: SourceMutationTarget): Element | null {
if (target.id) {
const byId = document.getElementById(target.id);
@@ -25,7 +39,7 @@ function findTargetElement(document: Document, target: SourceMutationTarget): El
if (!target.selector) return null;
try {
const matches = Array.from(document.querySelectorAll(target.selector));
const matches = querySelectorAllWithTemplates(document, target.selector);
return matches[target.selectorIndex ?? 0] ?? null;
} catch {
return null;
@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
import { createStudioMotionRenderBodyScript } from "./studioMotionRenderScript";
function runScript(window: Window, script: string): void {
const execute = new Function("window", "document", "HTMLElement", script);
execute(window, window.document, window.HTMLElement);
}
function installFakeGsap(window: Window): {
calls: Array<{
target: HTMLElement;
from: Record<string, unknown>;
to: Record<string, unknown>;
at: number;
}>;
timeCalls: number[];
customEaseCalls: Array<{ id: string; data: string }>;
killCalls: number;
} {
const state = {
calls: [] as Array<{
target: HTMLElement;
from: Record<string, unknown>;
to: Record<string, unknown>;
at: number;
}>,
timeCalls: [] as number[],
customEaseCalls: [] as Array<{ id: string; data: string }>,
killCalls: 0,
};
const timeline = {
fromTo(
target: HTMLElement,
from: Record<string, unknown>,
to: Record<string, unknown>,
at: number,
) {
state.calls.push({ target, from, to, at });
return timeline;
},
time(value: number) {
state.timeCalls.push(value);
return timeline;
},
pause() {
return timeline;
},
kill() {
state.killCalls += 1;
},
duration() {
return 2;
},
};
(
window as unknown as {
gsap: {
timeline: () => typeof timeline;
set: (target: HTMLElement, vars: Record<string, unknown>) => void;
};
CustomEase: { create: (id: string, data: string) => void };
__player?: { getTime: () => number };
}
).gsap = {
timeline: () => timeline,
set(target, vars) {
if (vars.clearProps === "transform,opacity,visibility") {
target.style.removeProperty("transform");
target.style.removeProperty("opacity");
target.style.removeProperty("visibility");
}
},
};
(
window as unknown as {
CustomEase: { create: (id: string, data: string) => void };
}
).CustomEase = {
create(id, data) {
state.customEaseCalls.push({ id, data });
},
};
return state;
}
describe("createStudioMotionRenderBodyScript", () => {
it("returns null for an empty manifest", () => {
expect(createStudioMotionRenderBodyScript("")).toBeNull();
});
it("returns null for a valid manifest without motions", () => {
expect(createStudioMotionRenderBodyScript(`{"version":1,"motions":[]}`)).toBeNull();
});
it("registers Studio-authored GSAP motion into window.__timelines", () => {
const window = new Window();
window.document.body.innerHTML = '<div id="card" style="opacity: 0.6"></div>';
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) throw new Error("card fixture missing");
const gsapState = installFakeGsap(window);
(
window as unknown as {
__player: { getTime: () => number };
}
).__player = { getTime: () => 0.5 };
const script = createStudioMotionRenderBodyScript(
JSON.stringify({
version: 1,
motions: [
{
kind: "gsap-motion",
target: { sourceFile: "index.html", id: "card" },
start: 0.2,
duration: 0.7,
ease: "power2.out",
from: { y: 32, autoAlpha: 0 },
to: { y: 0, autoAlpha: 1 },
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(gsapState.calls[0]).toMatchObject({
target: card,
from: { y: 32, autoAlpha: 0 },
to: { y: 0, autoAlpha: 1, duration: 0.7, ease: "power2.out" },
at: 0.2,
});
expect(gsapState.timeCalls).toEqual([0.5]);
expect(
(window as unknown as { __timelines?: Record<string, unknown> }).__timelines?.[
"studio-motion"
],
).toBeTruthy();
});
it("does not mutate when GSAP is unavailable", () => {
const window = new Window();
window.document.body.innerHTML = '<div id="card" style="opacity: 0.6"></div>';
const script = createStudioMotionRenderBodyScript(
JSON.stringify({
version: 1,
motions: [
{
kind: "gsap-motion",
target: { sourceFile: "index.html", id: "card" },
start: 0,
duration: 1,
ease: "none",
from: { x: 0 },
to: { x: 10 },
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(
(window as unknown as { __timelines?: Record<string, unknown> }).__timelines?.[
"studio-motion"
],
).toBeUndefined();
});
it("registers CustomEase data before adding Studio motion tweens", () => {
const window = new Window();
window.document.body.innerHTML = '<div id="card"></div>';
const gsapState = installFakeGsap(window);
const script = createStudioMotionRenderBodyScript(
JSON.stringify({
version: 1,
motions: [
{
kind: "gsap-motion",
target: { sourceFile: "index.html", id: "card" },
start: 0,
duration: 1,
ease: "studio-card-bounce",
customEase: {
id: "studio-card-bounce",
data: "M0,0 C0.18,0.9 0.32,1 1,1",
},
from: { y: 32 },
to: { y: 0 },
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(gsapState.customEaseCalls).toEqual([
{ id: "studio-card-bounce", data: "M0,0 C0.18,0.9 0.32,1 1,1" },
]);
expect(gsapState.calls[0]?.to.ease).toBe("studio-card-bounce");
});
});
@@ -0,0 +1,260 @@
export interface StudioMotionRenderScriptOptions {
activeCompositionPath?: string | null;
}
export const STUDIO_MOTION_PATH = ".hyperframes/studio-motion.json";
function hasStudioMotionEntries(manifestContent: string): boolean {
try {
const parsed = JSON.parse(manifestContent) as { motions?: unknown };
return Array.isArray(parsed.motions) && parsed.motions.length > 0;
} catch {
return false;
}
}
/**
* Builds the render-time Studio motion runtime script, or null when no owned motion exists.
*/
export function createStudioMotionRenderBodyScript(
manifestContent: string,
options: StudioMotionRenderScriptOptions = {},
): string | null {
if (!manifestContent.trim() || !hasStudioMotionEntries(manifestContent)) return null;
return `(${studioMotionRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`;
}
function studioMotionRenderRuntime(
manifestContent: string,
activeCompositionPath: string | null,
): void {
const STUDIO_MOTION_TIMELINE_ID = "studio-motion";
const STUDIO_MOTION_ATTR = "data-hf-studio-motion";
const ORIGINAL_TRANSFORM_ATTR = "data-hf-studio-motion-original-transform";
const ORIGINAL_OPACITY_ATTR = "data-hf-studio-motion-original-opacity";
const ORIGINAL_VISIBILITY_ATTR = "data-hf-studio-motion-original-visibility";
const objectRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === "object" ? (value as Record<string, unknown>) : null;
const finiteNumber = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
const runtimeWindow = window as Window & {
gsap?: {
timeline?: (vars?: Record<string, unknown>) => {
fromTo?: (
target: HTMLElement,
from: Record<string, unknown>,
to: Record<string, unknown>,
at: number,
) => unknown;
totalTime?: (time: number, suppressEvents?: boolean) => unknown;
time?: (time: number) => unknown;
pause?: () => unknown;
kill?: () => unknown;
};
set?: (target: HTMLElement, vars: Record<string, unknown>) => unknown;
registerPlugin?: (...plugins: unknown[]) => unknown;
};
CustomEase?: { create?: (id: string, data: string) => unknown };
__player?: { getTime?: () => number };
__timeline?: { time?: () => number };
__timelines?: Record<
string,
| {
kill?: () => unknown;
}
| undefined
>;
__hfStudioMotionApply?: () => number;
};
const parseMotionValues = (value: unknown): Record<string, number> | null => {
const record = objectRecord(value);
if (!record) return null;
const parsed: Record<string, number> = {};
for (const key of ["x", "y", "scale", "rotation", "opacity", "autoAlpha"]) {
const next = finiteNumber(record[key]);
if (next != null) parsed[key] = next;
}
return Object.keys(parsed).length > 0 ? parsed : null;
};
const parseCustomEase = (value: unknown): { id: string; data: string } | null => {
const record = objectRecord(value);
if (!record) return null;
const id = typeof record.id === "string" ? record.id.trim() : "";
const data = typeof record.data === "string" ? record.data.trim() : "";
if (!id || !data) return null;
return { id, data };
};
const parsedManifest = (() => {
try {
return objectRecord(JSON.parse(manifestContent));
} catch {
return null;
}
})();
const manifestMotions = Array.isArray(parsedManifest?.motions) ? parsedManifest.motions : [];
const sourceFileForElement = (element: HTMLElement): string => {
let current: HTMLElement | null = element;
while (current) {
const sourceFile =
current.getAttribute("data-composition-file") ??
current.getAttribute("data-composition-src");
if (sourceFile) return sourceFile;
current = current.parentElement;
}
return activeCompositionPath ?? "index.html";
};
const elementMatchesSourceFile = (element: HTMLElement, sourceFile: string): boolean =>
sourceFileForElement(element) === sourceFile;
const isHTMLElement = (element: Element | null): element is HTMLElement =>
element instanceof HTMLElement;
const querySelectorCandidates = (selector: string): HTMLElement[] => {
const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1];
if (className) {
return Array.from(document.getElementsByTagName("*")).filter(
(element): element is HTMLElement =>
isHTMLElement(element) && element.classList.contains(className),
);
}
if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) {
return Array.from(document.getElementsByTagName(selector)).filter(isHTMLElement);
}
return Array.from(document.querySelectorAll(selector)).filter(isHTMLElement);
};
const resolveTarget = (targetRecord: Record<string, unknown>): HTMLElement | null => {
const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : "";
if (!sourceFile) return null;
const id = typeof targetRecord.id === "string" ? targetRecord.id : "";
if (id) {
const byId = document.getElementById(id);
if (isHTMLElement(byId) && elementMatchesSourceFile(byId, sourceFile)) return byId;
}
const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : "";
if (!selector) return null;
try {
const selectorIndex = Math.max(0, Math.floor(finiteNumber(targetRecord.selectorIndex) ?? 0));
return (
querySelectorCandidates(selector).filter((element) =>
elementMatchesSourceFile(element, sourceFile),
)[selectorIndex] ?? null
);
} catch {
return null;
}
};
const restoreElement = (element: HTMLElement): void => {
runtimeWindow.gsap?.set?.(element, { clearProps: "transform,opacity,visibility" });
element.style.transform = element.getAttribute(ORIGINAL_TRANSFORM_ATTR) ?? "";
element.style.opacity = element.getAttribute(ORIGINAL_OPACITY_ATTR) ?? "";
element.style.visibility = element.getAttribute(ORIGINAL_VISIBILITY_ATTR) ?? "";
element.removeAttribute(STUDIO_MOTION_ATTR);
element.removeAttribute(ORIGINAL_TRANSFORM_ATTR);
element.removeAttribute(ORIGINAL_OPACITY_ATTR);
element.removeAttribute(ORIGINAL_VISIBILITY_ATTR);
};
const restoreStudioMotionElements = (): void => {
for (const element of Array.from(document.querySelectorAll(`[${STUDIO_MOTION_ATTR}]`))) {
if (isHTMLElement(element)) restoreElement(element);
}
};
const readCurrentTime = (): number => {
try {
const playerTime = runtimeWindow.__player?.getTime?.();
if (typeof playerTime === "number" && Number.isFinite(playerTime)) {
return Math.max(0, playerTime);
}
} catch {
// fall through
}
try {
const timelineTime = runtimeWindow.__timeline?.time?.();
if (typeof timelineTime === "number" && Number.isFinite(timelineTime)) {
return Math.max(0, timelineTime);
}
} catch {
// fall through
}
return 0;
};
const resolveEase = (motion: Record<string, unknown>): string => {
const fallback =
typeof motion.ease === "string" && motion.ease.trim() ? motion.ease.trim() : "none";
const customEase = parseCustomEase(motion.customEase);
const customEasePlugin = runtimeWindow.CustomEase;
if (!customEase || typeof customEasePlugin?.create !== "function") return fallback;
try {
runtimeWindow.gsap?.registerPlugin?.(customEasePlugin);
customEasePlugin.create(customEase.id, customEase.data);
return customEase.id;
} catch {
return fallback;
}
};
const applyManifest = (): number => {
runtimeWindow.__timelines = runtimeWindow.__timelines ?? {};
runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID]?.kill?.();
delete runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID];
restoreStudioMotionElements();
const gsap = runtimeWindow.gsap;
if (!gsap?.timeline || manifestMotions.length === 0) return 0;
const timeline = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } });
let applied = 0;
for (const motionValue of manifestMotions) {
const motion = objectRecord(motionValue);
if (!motion || motion.kind !== "gsap-motion") continue;
const targetRecord = objectRecord(motion.target);
if (!targetRecord) continue;
const target = resolveTarget(targetRecord);
if (!target || typeof timeline.fromTo !== "function") continue;
const start = finiteNumber(motion.start);
const duration = finiteNumber(motion.duration);
if (start == null || duration == null || start < 0 || duration <= 0) continue;
const from = parseMotionValues(motion.from);
const to = parseMotionValues(motion.to);
if (!from || !to) continue;
if (!target.hasAttribute(STUDIO_MOTION_ATTR)) {
target.setAttribute(ORIGINAL_TRANSFORM_ATTR, target.style.transform);
target.setAttribute(ORIGINAL_OPACITY_ATTR, target.style.opacity);
target.setAttribute(ORIGINAL_VISIBILITY_ATTR, target.style.visibility);
}
target.setAttribute(STUDIO_MOTION_ATTR, "true");
timeline.fromTo(
target,
from,
{ ...to, duration, ease: resolveEase(motion), overwrite: "auto", immediateRender: false },
start,
);
applied += 1;
}
if (applied === 0) {
timeline.kill?.();
return 0;
}
runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID] = timeline;
timeline.pause?.();
const currentTime = readCurrentTime();
if (typeof timeline.totalTime === "function") timeline.totalTime(currentTime, false);
else timeline.time?.(currentTime);
return applied;
};
runtimeWindow.__hfStudioMotionApply = applyManifest;
applyManifest();
}
@@ -23,6 +23,7 @@ describe("buildSubCompositionHtml", () => {
"compositions/hero.html": `<template id="hero-template">
<div data-composition-id="hero" data-width="1920" data-height="1080">
<img src="../logo.png" alt="Logo" />
<div style="background-image: url('../poster.png')"></div>
<style>
@font-face {
font-family: "Brand Sans";
@@ -42,8 +43,10 @@ describe("buildSubCompositionHtml", () => {
expect(html).toContain('<base href="/api/projects/demo/preview/">');
expect(html).toContain('src="logo.png"');
expect(html).toContain("background-image: url('poster.png')");
expect(html).toContain('url("fonts/brand.woff2")');
expect(html).not.toContain('src="../logo.png"');
expect(html).not.toContain("url('../poster.png')");
expect(html).not.toContain('url("../fonts/brand.woff2")');
});
});
@@ -1,7 +1,11 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { parseHTML } from "linkedom";
import { rewriteAssetPaths, rewriteCssAssetUrls } from "../../compiler/rewriteSubCompPaths.js";
import {
rewriteAssetPaths,
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "../../compiler/rewriteSubCompPaths.js";
/**
* Build a standalone HTML page for a sub-composition.
@@ -36,6 +40,14 @@ export function buildSubCompositionHtml(
el.setAttribute(attr, value);
},
);
rewriteInlineStyleAssetUrls(
contentDoc.querySelectorAll("[style]"),
compPath,
(el: Element) => el.getAttribute("style"),
(el: Element, value: string) => {
el.setAttribute("style", value);
},
);
for (const styleEl of contentDoc.querySelectorAll("style")) {
styleEl.textContent = rewriteCssAssetUrls(styleEl.textContent || "", compPath);
}
+10
View File
@@ -5,3 +5,13 @@ export { isSafePath, walkDir } from "./helpers/safePath.js";
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
export { buildSubCompositionHtml } from "./helpers/subComposition.js";
export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screenshotClip.js";
export {
STUDIO_MANUAL_EDITS_PATH,
createStudioManualEditsRenderBodyScript,
type StudioManualEditsRenderScriptOptions,
} from "./helpers/manualEditsRenderScript.js";
export {
STUDIO_MOTION_PATH,
createStudioMotionRenderBodyScript,
type StudioMotionRenderScriptOptions,
} from "./helpers/studioMotionRenderScript.js";
+15 -2
View File
@@ -306,8 +306,21 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const skipped: string[] = [];
const invalid: Array<{ name: string; reason: string }> = [];
for (const [, value] of formData.entries()) {
if (!(value instanceof File)) continue;
// @types/node v25 narrows the ambient `FormData.entries()` to
// `[string, string]` in workspaces where another dep declares an
// `onmessage` global (it trips the worker branch of v25's conditional
// File type). At runtime the value is still `File | string` — cast the
// iterator so the rest of this block keeps type-checking on every
// bun-install layout (hoisted on Windows surfaces this; isolated on
// Linux happens to keep v24 in scope).
type FileLike = {
readonly name: string;
readonly size: number;
arrayBuffer(): Promise<ArrayBuffer>;
};
const entries = formData.entries() as unknown as Iterable<[string, FileLike | string]>;
for (const [, value] of entries) {
if (typeof value === "string") continue;
// Strip path separators — browsers may include directory components
const name = value.name.split("/").pop()?.split("\\").pop() ?? "";
@@ -0,0 +1,247 @@
import { execFileSync } from "node:child_process";
import { existsSync, readdirSync, statSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import type { Hono } from "hono";
const FONT_EXT_RE = /\.(otf|ttf|ttc|woff2?)$/i;
const MAX_FONT_RESULTS = 2000;
const GOOGLE_FONTS_METADATA_URL = "https://fonts.google.com/metadata/fonts";
const GOOGLE_FONTS_FETCH_TIMEOUT_MS = 3000;
let cachedFonts: string[] | null = null;
let cachedGoogleFonts: string[] | null = null;
const STYLE_SUFFIXES = new Set([
"black",
"bold",
"book",
"condensed",
"demi",
"demibold",
"display",
"extra",
"extrabold",
"hairline",
"heavy",
"italic",
"light",
"medium",
"normal",
"regular",
"roman",
"semibold",
"thin",
"ultra",
"ultralight",
]);
const GOOGLE_FONT_FALLBACKS = [
"Inter",
"Roboto",
"Open Sans",
"Montserrat",
"Poppins",
"Lato",
"Oswald",
"Raleway",
"Nunito",
"Playfair Display",
"Merriweather",
"Source Sans 3",
"Source Serif 4",
"Source Code Pro",
"DM Sans",
"Space Grotesk",
"Space Mono",
"Bebas Neue",
"Outfit",
"JetBrains Mono",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function fontDirectories(): string[] {
const home = homedir();
if (platform() === "darwin") {
return [
join(home, "Library", "Fonts"),
"/Library/Fonts",
"/System/Library/Fonts",
"/System/Library/Fonts/Supplemental",
];
}
if (platform() === "win32") {
return [join(process.env.WINDIR || "C:\\Windows", "Fonts")];
}
return [
join(home, ".fonts"),
join(home, ".local", "share", "fonts"),
"/usr/local/share/fonts",
"/usr/share/fonts",
];
}
function toFamilyName(fileName: string): string | null {
const withoutExt = fileName.replace(FONT_EXT_RE, "");
if (!withoutExt || withoutExt.startsWith(".")) return null;
const spaced = withoutExt
.replace(/[_-]+/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim();
const words = spaced.split(" ").filter(Boolean);
while (words.length > 1 && STYLE_SUFFIXES.has((words.at(-1) ?? "").toLowerCase())) {
words.pop();
}
const family = words.join(" ").trim();
return family.length >= 2 ? family : null;
}
function collectMacSystemProfilerFonts(): string[] {
if (platform() !== "darwin") return [];
let parsed: unknown;
try {
const raw = execFileSync("system_profiler", ["SPFontsDataType", "-json"], {
encoding: "utf8",
maxBuffer: 12 * 1024 * 1024,
timeout: 5000,
});
parsed = JSON.parse(raw);
} catch {
return [];
}
if (!isRecord(parsed) || !Array.isArray(parsed.SPFontsDataType)) return [];
const fonts: string[] = [];
for (const fontEntry of parsed.SPFontsDataType) {
if (!isRecord(fontEntry)) continue;
const typefaces = fontEntry.typefaces;
if (!Array.isArray(typefaces)) continue;
for (const typeface of typefaces) {
if (!isRecord(typeface)) continue;
const family = typeface.family;
const fullName = typeface.fullname;
const name = typeface._name;
if (typeof family === "string" && family.trim()) {
fonts.push(family.trim());
} else if (typeof fullName === "string" && fullName.trim()) {
fonts.push(fullName.trim());
} else if (typeof name === "string" && name.trim()) {
fonts.push(name.trim());
}
}
}
return fonts;
}
function collectFontsFromDir(dir: string, depth = 0): string[] {
if (!existsSync(dir) || depth > 2) return [];
const fonts: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
fonts.push(...collectFontsFromDir(fullPath, depth + 1));
continue;
}
if (!entry.isFile() || !FONT_EXT_RE.test(entry.name)) continue;
try {
if (!statSync(fullPath).isFile()) continue;
} catch {
continue;
}
const family = toFamilyName(entry.name);
if (family) fonts.push(family);
}
return fonts;
}
function listInstalledFontFamilies(): string[] {
if (cachedFonts) return cachedFonts;
const families = new Set<string>();
for (const family of collectMacSystemProfilerFonts()) {
families.add(family);
if (families.size >= MAX_FONT_RESULTS) break;
}
for (const dir of fontDirectories()) {
for (const family of collectFontsFromDir(dir)) {
families.add(family);
if (families.size >= MAX_FONT_RESULTS) break;
}
if (families.size >= MAX_FONT_RESULTS) break;
}
cachedFonts = Array.from(families).sort((a, b) => a.localeCompare(b));
return cachedFonts;
}
function parseGoogleFontMetadata(value: unknown): string[] {
if (!isRecord(value) || !Array.isArray(value.familyMetadataList)) return [];
const families: string[] = [];
for (const entry of value.familyMetadataList) {
if (!isRecord(entry) || typeof entry.family !== "string") continue;
families.push(entry.family);
}
return families;
}
function stripGoogleJsonGuard(raw: string): string {
const prefix = ")]}'";
if (!raw.startsWith(prefix)) return raw;
let index = prefix.length;
while (
index < raw.length &&
(raw[index] === " " ||
raw[index] === "\n" ||
raw[index] === "\r" ||
raw[index] === "\t" ||
raw[index] === "\f")
) {
index += 1;
}
return raw.slice(index);
}
async function listGoogleFontFamilies(): Promise<string[]> {
if (cachedGoogleFonts) return cachedGoogleFonts;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), GOOGLE_FONTS_FETCH_TIMEOUT_MS);
try {
const response = await fetch(GOOGLE_FONTS_METADATA_URL, { signal: controller.signal });
if (!response.ok) {
cachedGoogleFonts = GOOGLE_FONT_FALLBACKS;
return cachedGoogleFonts;
}
const raw = await response.text();
const jsonText = stripGoogleJsonGuard(raw);
const families = parseGoogleFontMetadata(JSON.parse(jsonText));
cachedGoogleFonts = families.length > 0 ? families : GOOGLE_FONT_FALLBACKS;
} catch {
cachedGoogleFonts = GOOGLE_FONT_FALLBACKS;
} finally {
clearTimeout(timer);
}
return cachedGoogleFonts;
}
export function registerFontRoutes(api: Hono): void {
api.get("/fonts", (c) => c.json({ fonts: listInstalledFontFamilies() }));
api.get("/fonts/google", async (c) => c.json({ fonts: await listGoogleFontFamilies() }));
}
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerPreviewRoutes } from "./preview";
@@ -64,6 +64,201 @@ async function getPreviewSignature(projectDir: string): Promise<string> {
}
describe("registerPreviewRoutes", () => {
it("injects Studio GSAP motion manifest runtime into project preview", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
"<!doctype html><html><head></head><body><div id='card'></div></body></html>",
);
const manifestDir = join(projectDir, ".hyperframes");
mkdirSync(manifestDir, { recursive: true });
writeFileSync(
join(manifestDir, "studio-motion.json"),
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"index.html","id":"card"},"start":0,"duration":1,"ease":"power2.out","from":{"y":32},"to":{"y":0}}]}`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain("__hfStudioMotionApply");
expect(html).toContain("studio-motion");
expect(html).toContain("gsap@3.15.0/dist/gsap.min.js");
});
it("injects the GSAP CustomEase plugin when Studio motion uses a custom ease", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
"<!doctype html><html><head></head><body><div id='card'></div></body></html>",
);
const manifestDir = join(projectDir, ".hyperframes");
mkdirSync(manifestDir, { recursive: true });
writeFileSync(
join(manifestDir, "studio-motion.json"),
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"index.html","id":"card"},"start":0,"duration":1,"ease":"studio-card-ease","customEase":{"id":"studio-card-ease","data":"M0,0 C0.18,0.9 0.32,1 1,1"},"from":{"y":32},"to":{"y":0}}]}`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain("gsap@3.15.0/dist/gsap.min.js");
expect(html).toContain("gsap@3.15.0/dist/CustomEase.min.js");
expect(html.indexOf("gsap.min.js")).toBeLessThan(html.indexOf("CustomEase.min.js"));
expect(html.indexOf("CustomEase.min.js")).toBeLessThan(html.indexOf("__hfStudioMotionApply"));
});
it("injects Studio GSAP motion runtime into sub-composition previews with the active source path", async () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, "compositions"), { recursive: true });
writeFileSync(
join(projectDir, "index.html"),
"<!doctype html><html><head></head><body></body></html>",
);
writeFileSync(
join(projectDir, "compositions/scene.html"),
`<template><section id="card" data-composition-id="scene" data-width="1280" data-height="720"></section></template>`,
);
const manifestDir = join(projectDir, ".hyperframes");
mkdirSync(manifestDir, { recursive: true });
writeFileSync(
join(manifestDir, "studio-motion.json"),
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"compositions/scene.html","id":"card"},"start":0,"duration":1,"ease":"power2.out","from":{"y":32},"to":{"y":0}}]}`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const response = await app.request(
"http://localhost/projects/demo/preview/comp/compositions/scene.html",
);
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain("__hfStudioMotionApply");
expect(html).toContain("compositions/scene.html");
});
it("applies adapter preview transforms to bundled root previews", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
bundle: async () => "<!doctype html><html><head></head><body>Preview</body></html>",
transformPreviewHtml: async ({ html, activeCompositionPath }) =>
html.replace(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="index.html">');
});
it("applies adapter preview transforms to sub-composition previews", async () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, "compositions"), { recursive: true });
writeFileSync(
join(projectDir, "compositions/scene.html"),
`<template><section data-composition-id="scene" data-width="1280" data-height="720"></section></template>`,
);
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
transformPreviewHtml: async ({ html, activeCompositionPath }) =>
html.replace(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);
const response = await app.request(
"http://localhost/projects/demo/preview/comp/compositions/scene.html",
);
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="compositions/scene.html">');
});
it("applies adapter preview transforms when bundle() returns null (reads from disk)", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
// bundle: async () => null <-- default; falls back to reading index.html from disk
transformPreviewHtml: async ({ html, activeCompositionPath }) =>
html.replace(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="index.html">');
});
it("applies adapter preview transforms in the bundle error fallback path", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
bundle: async () => {
throw new Error("bundler unavailable");
},
transformPreviewHtml: async ({ html, activeCompositionPath }) =>
html.replace(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="index.html">');
});
it("falls back to original HTML when transformPreviewHtml throws", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
bundle: async () => "<!doctype html><html><head></head><body>Preview</body></html>",
transformPreviewHtml: async () => {
throw new Error("transform failed");
},
}),
);
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain("Preview");
});
it("uses the adapter project signature when available", async () => {
const projectDir = createProjectDir();
const getProjectSignature = vi.fn(() => "cached-signature");
@@ -93,6 +288,23 @@ describe("registerPreviewRoutes", () => {
await expect(getPreviewSignature(projectDir)).resolves.not.toBe(firstSignature);
});
it("updates the preview signature after Studio manifest edits", async () => {
const projectDir = createProjectDir();
const manifestDir = join(projectDir, ".hyperframes");
mkdirSync(manifestDir, { recursive: true });
const motionFile = join(manifestDir, "studio-motion.json");
writeFileSync(motionFile, `{"version":1,"motions":[]}`);
const firstSignature = await getPreviewSignature(projectDir);
writeFileSync(
motionFile,
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"index.html","id":"card"},"start":0,"duration":1,"from":{"y":32},"to":{"y":0}}]}`,
);
await expect(getPreviewSignature(projectDir)).resolves.not.toBe(firstSignature);
});
it("skips symlinked files when creating the preview signature", async () => {
const projectDir = createProjectDir();
const firstSignature = await getPreviewSignature(projectDir);
+178 -9
View File
@@ -1,13 +1,21 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { join, resolve } from "node:path";
import { injectScriptsIntoHtml } from "../../compiler/htmlDocument.js";
import type { StudioApiAdapter } from "../types.js";
import { isSafePath } from "../helpers/safePath.js";
import { getMimeType } from "../helpers/mime.js";
import { buildSubCompositionHtml } from "../helpers/subComposition.js";
import { createProjectSignature } from "../helpers/projectSignature.js";
import {
createStudioMotionRenderBodyScript,
STUDIO_MOTION_PATH,
} from "../helpers/studioMotionRenderScript.js";
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
const GSAP_CDN_VERSION = "3.15.0";
const GSAP_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/gsap.min.js"></script>`;
const GSAP_CUSTOM_EASE_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/CustomEase.min.js"></script>`;
function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string {
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
@@ -25,12 +33,134 @@ function injectProjectSignature(html: string, signature: string): string {
return `${tag}\n${html}`;
}
function readStudioMotionManifestContent(projectDir: string): string {
const manifestPath = join(projectDir, STUDIO_MOTION_PATH);
if (!existsSync(manifestPath)) return "";
try {
return readFileSync(manifestPath, "utf-8");
} catch {
return "";
}
}
function parseStudioMotionManifestContent(content: string): {
hasMotion: boolean;
hasCustomEase: boolean;
} {
try {
const parsed = JSON.parse(content) as { motions?: Array<{ customEase?: unknown }> };
const motions = Array.isArray(parsed.motions) ? parsed.motions : [];
return {
hasMotion: motions.length > 0,
hasCustomEase: motions.some((motion) => Boolean(motion?.customEase)),
};
} catch {
return { hasMotion: false, hasCustomEase: false };
}
}
function injectScriptTagIntoHead(html: string, scriptTag: string): string {
if (html.includes("</head>")) return html.replace("</head>", `${scriptTag}\n</head>`);
return `${scriptTag}\n${html}`;
}
function htmlHasGsap(html: string): boolean {
// Keep this heuristic conservative: if user source already loads GSAP, Studio does not add another copy.
return (
/<script\b[^>]*src=["'][^"']*gsap/i.test(html) ||
/\/\*\s*inlined:.*gsap/i.test(html) ||
/\b(GreenSock|_gsScope)\b/.test(html) ||
/\bgsap\.(config|defaults|registerPlugin|version)\b/.test(html)
);
}
function htmlHasCustomEase(html: string): boolean {
return (
/<script\b[^>]*src=["'][^"']*CustomEase/i.test(html) ||
/\bwindow\.CustomEase\b/.test(html) ||
/\bCustomEase\s*=\s*/.test(html)
);
}
function injectStudioMotionDependencies(html: string, manifestContent: string): string {
const manifest = parseStudioMotionManifestContent(manifestContent);
if (!manifest.hasMotion) return html;
let next = html;
if (!htmlHasGsap(next)) next = injectScriptTagIntoHead(next, GSAP_CDN_SCRIPT);
if (manifest.hasCustomEase && !htmlHasCustomEase(next)) {
next = injectScriptTagIntoHead(next, GSAP_CUSTOM_EASE_CDN_SCRIPT);
}
return next;
}
function injectStudioMotionScript(
html: string,
projectDir: string,
activeCompositionPath: string,
): string {
const manifestContent = readStudioMotionManifestContent(projectDir);
const script = createStudioMotionRenderBodyScript(manifestContent, {
activeCompositionPath,
});
if (!script) return html;
return injectScriptsIntoHtml(
injectStudioMotionDependencies(html, manifestContent),
[],
[script],
false,
);
}
function injectStudioPreviewAugmentations(
html: string,
adapter: StudioApiAdapter,
projectDir: string,
activeCompositionPath: string,
): string {
return injectStudioMotionScript(
injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
projectDir,
activeCompositionPath,
);
}
async function transformPreviewHtml(
html: string,
adapter: StudioApiAdapter,
project: { id: string; dir: string; title?: string; sessionId?: string },
activeCompositionPath: string,
): Promise<string> {
if (!adapter.transformPreviewHtml) return html;
try {
return await adapter.transformPreviewHtml({
html,
project,
activeCompositionPath,
});
} catch (err) {
console.warn("[Studio] preview transform failed, using original HTML:", err);
return html;
}
}
export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void {
const previewCacheHeaders = (etag: string) => ({
"Cache-Control": "private, no-cache",
ETag: etag,
});
// Bundled composition preview
api.get("/projects/:id/preview", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const signature = resolveProjectSignature(adapter, project.dir);
const etag = `"preview:${signature}"`;
const ifNoneMatch = c.req.header("If-None-Match");
if (ifNoneMatch === etag) {
return new Response(null, { status: 304, headers: previewCacheHeaders(etag) });
}
try {
let bundled = await adapter.bundle(project.dir);
if (!bundled) {
@@ -56,16 +186,25 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
}
bundled = injectProjectSignature(bundled, resolveProjectSignature(adapter, project.dir));
return c.html(bundled);
bundled = injectStudioPreviewAugmentations(
await transformPreviewHtml(bundled, adapter, project, "index.html"),
adapter,
project.dir,
"index.html",
);
return c.html(bundled, 200, previewCacheHeaders(etag));
} catch {
const file = resolve(project.dir, "index.html");
if (existsSync(file)) {
return c.html(
injectProjectSignature(
readFileSync(file, "utf-8"),
resolveProjectSignature(adapter, project.dir),
injectStudioPreviewAugmentations(
await transformPreviewHtml(readFileSync(file, "utf-8"), adapter, project, "index.html"),
adapter,
project.dir,
"index.html",
),
200,
previewCacheHeaders(etag),
);
}
return c.text("not found", 404);
@@ -76,6 +215,8 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
api.get("/projects/:id/preview/comp/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const signature = resolveProjectSignature(adapter, project.dir);
const compPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? "",
);
@@ -87,10 +228,22 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
) {
return c.text("not found", 404);
}
const etag = `"comp:${compPath}:${signature}"`;
const ifNoneMatch = c.req.header("If-None-Match");
if (ifNoneMatch === etag) {
return new Response(null, { status: 304, headers: previewCacheHeaders(etag) });
}
const baseHref = `/api/projects/${project.id}/preview/`;
const html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref);
let html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref);
if (!html) return c.text("not found", 404);
return c.html(injectProjectSignature(html, resolveProjectSignature(adapter, project.dir)));
html = await transformPreviewHtml(html, adapter, project, compPath);
return c.html(
injectStudioPreviewAugmentations(html, adapter, project.dir, compPath),
200,
previewCacheHeaders(etag),
);
});
// Static asset serving (with range request support for audio/video seeking)
@@ -101,11 +254,25 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? "",
);
const file = resolve(project.dir, subPath);
if (!isSafePath(project.dir, file) || !existsSync(file) || !statSync(file).isFile()) {
const stat = existsSync(file) ? statSync(file) : null;
if (!isSafePath(project.dir, file) || !stat?.isFile()) {
return c.text("not found", 404);
}
const contentType = getMimeType(subPath);
const isText = /\.(html|css|js|json|svg|txt|md)$/i.test(subPath);
const etag = `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}"`;
const cacheHeaders: Record<string, string> = isText
? { "Cache-Control": "no-store" }
: { "Cache-Control": "private, max-age=3600, must-revalidate", ETag: etag };
if (!isText) {
const ifNoneMatch = c.req.header("If-None-Match");
if (ifNoneMatch === etag) {
return new Response(null, { status: 304, headers: cacheHeaders });
}
}
const buffer: Buffer = isText
? Buffer.from(readFileSync(file, "utf-8"), "utf-8")
: readFileSync(file);
@@ -123,6 +290,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
return new Response(new Uint8Array(buffer.slice(start, safeEnd + 1)), {
status: 206,
headers: {
...cacheHeaders,
"Content-Type": contentType,
"Content-Range": `bytes ${start}-${safeEnd}/${totalSize}`,
"Accept-Ranges": "bytes",
@@ -134,6 +302,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
return new Response(new Uint8Array(buffer), {
headers: {
...cacheHeaders,
"Content-Type": contentType,
"Accept-Ranges": "bytes",
"Content-Length": String(totalSize),
@@ -25,6 +25,6 @@ export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): voi
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const files = walkDir(project.dir);
return c.json({ id: project.id, files });
return c.json({ id: project.id, dir: project.dir, title: project.title, files });
});
}
@@ -3,6 +3,7 @@ import { Hono } from "hono";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { VALID_CANVAS_RESOLUTIONS } from "../../core.types";
import { registerRenderRoutes } from "./render";
import type { StudioApiAdapter } from "../types";
@@ -98,8 +99,8 @@ describe("POST /projects/:id/render — outputResolution forwarding", () => {
}
});
it("accepts each of the four canonical preset values", async () => {
for (const preset of ["landscape", "portrait", "landscape-4k", "portrait-4k"] as const) {
it("accepts each canonical preset value", async () => {
for (const preset of VALID_CANVAS_RESOLUTIONS) {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerThumbnailRoutes } from "./thumbnail";
@@ -94,4 +94,108 @@ describe("registerThumbnailRoutes", () => {
}),
);
});
it("forwards selector occurrence indexes to thumbnail generation", async () => {
const adapter = createAdapter();
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const response = await app.request(
"http://localhost/projects/demo/thumbnail/index.html?t=1.2&selector=.card&selectorIndex=2",
);
expect(response.status).toBe(200);
expect(adapter.generateThumbnail).toHaveBeenCalledWith(
expect.objectContaining({
selector: ".card",
selectorIndex: 2,
}),
);
});
it("keeps url thumbnail versions separated in the disk cache", async () => {
const adapter = createAdapter();
const app = new Hono();
registerThumbnailRoutes(app, adapter);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=old");
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=old");
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=new");
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
});
it("keeps changed composition dimensions separated in the disk cache", async () => {
const adapter = createAdapter();
const project = await adapter.resolveProject("demo");
if (!project) throw new Error("missing project");
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const indexPath = join(project.dir, "index.html");
writeFileSync(indexPath, `<div data-composition-id="main" data-width="640" data-height="360">`);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
writeFileSync(
indexPath,
`<div data-composition-id="main" data-width="1280" data-height="720">`,
);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
expect(adapter.generateThumbnail).toHaveBeenLastCalledWith(
expect.objectContaining({
width: 1280,
height: 720,
}),
);
});
it("keeps changed studio manual edits separated in the disk cache", async () => {
const adapter = createAdapter();
const project = await adapter.resolveProject("demo");
if (!project) throw new Error("missing project");
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const indexPath = join(project.dir, "index.html");
writeFileSync(indexPath, `<div data-composition-id="main" data-width="640" data-height="360">`);
const manualEditsDir = join(project.dir, ".hyperframes");
mkdirSync(manualEditsDir, { recursive: true });
const manualEditsPath = join(manualEditsDir, "studio-manual-edits.json");
writeFileSync(manualEditsPath, `{"version":1,"edits":[]}`);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
writeFileSync(
manualEditsPath,
`{"version":1,"edits":[{"kind":"rotation","target":{"sourceFile":"index.html","id":"card"},"angle":30}]}`,
);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
});
it("keeps changed studio motion separated in the disk cache", async () => {
const adapter = createAdapter();
const project = await adapter.resolveProject("demo");
if (!project) throw new Error("missing project");
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const indexPath = join(project.dir, "index.html");
writeFileSync(indexPath, `<div data-composition-id="main" data-width="640" data-height="360">`);
const motionDir = join(project.dir, ".hyperframes");
mkdirSync(motionDir, { recursive: true });
const motionPath = join(motionDir, "studio-motion.json");
writeFileSync(motionPath, `{"version":1,"motions":[]}`);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
writeFileSync(
motionPath,
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"index.html","id":"card"},"start":0,"duration":1,"ease":"power2.out","from":{"y":32},"to":{"y":0}}]}`,
);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
});
});
@@ -1,9 +1,12 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import type { StudioApiAdapter } from "../types.js";
import { STUDIO_MANUAL_EDITS_PATH } from "../helpers/manualEditsRenderScript.js";
import { STUDIO_MOTION_PATH } from "../helpers/studioMotionRenderScript.js";
const THUMBNAIL_CACHE_VERSION = "v3";
const THUMBNAIL_CACHE_VERSION = "v4";
export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/thumbnail/*", async (c) => {
@@ -27,13 +30,19 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
const selector = url.searchParams.get("selector") || undefined;
const format = url.searchParams.get("format") === "png" ? "png" : "jpeg";
const contentType = format === "png" ? "image/png" : "image/jpeg";
const rawSelectorIndex = Number.parseInt(url.searchParams.get("selectorIndex") || "0", 10);
const selectorIndex =
Number.isFinite(rawSelectorIndex) && rawSelectorIndex > 0 ? rawSelectorIndex : undefined;
const urlVersion = url.searchParams.get("v") || "";
// Determine composition dimensions from HTML
let compW = vpWidth || 1920;
let compH = vpHeight || 1080;
let sourceMtime = 0;
if (!vpWidth) {
const htmlFile = join(project.dir, compPath);
if (existsSync(htmlFile)) {
sourceMtime = Math.round(statSync(htmlFile).mtimeMs);
const html = readFileSync(htmlFile, "utf-8");
const wMatch = html.match(/data-width=["'](\d+)["']/);
const hMatch = html.match(/data-height=["'](\d+)["']/);
@@ -41,6 +50,20 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
if (hMatch?.[1]) compH = parseInt(hMatch[1]);
}
}
const manualEditsFile = join(project.dir, STUDIO_MANUAL_EDITS_PATH);
let manualEditsKey = "";
if (existsSync(manualEditsFile)) {
const manualEditsContent = readFileSync(manualEditsFile, "utf-8");
manualEditsKey = `_${createHash("sha1").update(manualEditsContent).digest("hex").slice(0, 16)}`;
sourceMtime = Math.max(sourceMtime, Math.round(statSync(manualEditsFile).mtimeMs));
}
const motionFile = join(project.dir, STUDIO_MOTION_PATH);
let motionKey = "";
if (existsSync(motionFile)) {
const motionContent = readFileSync(motionFile, "utf-8");
motionKey = `_${createHash("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
sourceMtime = Math.max(sourceMtime, Math.round(statSync(motionFile).mtimeMs));
}
const previewUrl =
compPath === "index.html"
@@ -50,9 +73,12 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
// Cache
const cacheDir = join(project.dir, ".thumbnails");
const selectorKey = selector
? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}`
? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}`
: "";
const cacheKey = `${THUMBNAIL_CACHE_VERSION}_${format}_${compPath.replace(/\//g, "_")}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
const urlVersionKey = urlVersion
? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}`
: "";
const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${manualEditsKey}${motionKey}_${format}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
const cachePath = join(cacheDir, cacheKey);
if (existsSync(cachePath)) {
return new Response(new Uint8Array(readFileSync(cachePath)), {
@@ -70,6 +96,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
previewUrl,
selector,
format,
selectorIndex,
});
if (!buffer) {
return c.json({ error: "Thumbnail generation returned null" }, 500);
+11
View File
@@ -52,6 +52,16 @@ export interface StudioApiAdapter {
/** URL to the hyperframe runtime JS (injected into preview HTML). */
runtimeUrl: string;
/**
* Optional: post-process preview HTML before Studio augments it.
* Useful when preview must mirror render-time compilation steps.
*/
transformPreviewHtml?: (opts: {
html: string;
project: ResolvedProject;
activeCompositionPath: string;
}) => Promise<string> | string;
/** Directory where render output files are stored. */
rendersDir(project: ResolvedProject): string;
@@ -90,6 +100,7 @@ export interface StudioApiAdapter {
previewUrl: string;
selector?: string;
format?: "jpeg" | "png";
selectorIndex?: number;
}) => Promise<Buffer | null>;
/** Optional: resolve session ID to project (multi-project mode). */
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/engine",
"version": "0.5.7",
"version": "0.6.4",
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
"repository": {
"type": "git",
@@ -27,7 +27,7 @@
"puppeteer-core": "^24.39.1"
},
"devDependencies": {
"@types/node": "^22.10.1",
"@types/node": "^25.0.10",
"@webgpu/types": "^0.1.69",
"typescript": "^5.7.2",
"vitest": "^3.2.4"
+15 -1
View File
@@ -65,11 +65,13 @@ export {
closeCaptureSession,
captureFrame,
captureFrameToBuffer,
discardWarmupCapture,
getCompositionDuration,
getCapturePerfSummary,
prepareCaptureSessionForReuse,
type CaptureSession,
type BeforeCaptureHook,
type DiscardWarmupInnerCapture,
} from "./services/frameCapture.js";
// ── Screenshot (BeginFrame) ─────────────────────────────────────────────────────
@@ -155,6 +157,13 @@ export {
// ── Utilities ──────────────────────────────────────────────────────────────────
export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
export {
assertSwiftShader,
readWebGlVendorInfo,
SwiftShaderAssertionError,
BROWSER_GPU_NOT_SOFTWARE,
} from "./utils/assertSwiftShader.js";
export {
extractMediaMetadata,
extractVideoMetadata,
@@ -166,7 +175,12 @@ export {
} from "./utils/ffprobe.js";
export { downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
export { runFfmpeg, type RunFfmpegOptions, type RunFfmpegResult } from "./utils/runFfmpeg.js";
export {
runFfmpeg,
formatFfmpegError,
type RunFfmpegOptions,
type RunFfmpegResult,
} from "./utils/runFfmpeg.js";
export {
decodePng,
@@ -391,6 +391,245 @@ describe("getEncoderPreset HDR", () => {
});
});
describe("buildEncoderArgs lockGopForChunkConcat", () => {
const baseOptions = { fps: { num: 30, den: 1 }, width: 1920, height: 1080 };
const inputArgs = ["-framerate", "30", "-i", "frames/%04d.png"];
// Default path must emit zero closed-GOP args — in-process renders rely on
// libx264/libx265 defaults to stay byte-identical with their PSNR baselines.
it("default (false) omits closed-GOP args for libx264", () => {
const args = buildEncoderArgs(
{ ...baseOptions, codec: "h264", preset: "medium", quality: 23 },
inputArgs,
"out.mp4",
);
expect(args).not.toContain("-g");
expect(args).not.toContain("-keyint_min");
expect(args).not.toContain("-force_key_frames");
expect(args).not.toContain("-sc_threshold");
const paramIdx = args.indexOf("-x264-params");
expect(args[paramIdx + 1]).not.toContain("scenecut=0");
expect(args[paramIdx + 1]).not.toContain("open-gop=0");
expect(args[paramIdx + 1]).not.toContain("repeat-headers=1");
});
it("default (false) omits closed-GOP args for libx265", () => {
const args = buildEncoderArgs(
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23 },
inputArgs,
"out.mp4",
);
expect(args).not.toContain("-g");
expect(args).not.toContain("-keyint_min");
expect(args).not.toContain("-force_key_frames");
expect(args).not.toContain("-sc_threshold");
const paramIdx = args.indexOf("-x265-params");
expect(args[paramIdx + 1]).not.toContain("scenecut=0");
expect(args[paramIdx + 1]).not.toContain("keyint=");
expect(args[paramIdx + 1]).not.toContain("open-gop=0");
expect(args[paramIdx + 1]).not.toContain("repeat-headers=1");
});
it("true appends closed-GOP ffmpeg flags and x264-params for libx264", () => {
const args = buildEncoderArgs(
{
...baseOptions,
codec: "h264",
preset: "medium",
quality: 23,
lockGopForChunkConcat: true,
gopSize: 240,
},
inputArgs,
"out.mp4",
);
expect(args[args.indexOf("-g") + 1]).toBe("240");
expect(args[args.indexOf("-keyint_min") + 1]).toBe("240");
expect(args[args.indexOf("-sc_threshold") + 1]).toBe("0");
expect(args[args.indexOf("-force_key_frames") + 1]).toBe("expr:eq(mod(n,240),0)");
const paramIdx = args.indexOf("-x264-params");
expect(args[paramIdx + 1]).toContain("scenecut=0");
expect(args[paramIdx + 1]).toContain("open-gop=0");
expect(args[paramIdx + 1]).toContain("repeat-headers=1");
// -bf 0 was already present for h264; closed-GOP doesn't change that.
expect(args).toContain("-bf");
expect(args[args.indexOf("-bf") + 1]).toBe("0");
// 90000 timescale is required for clean concat-copy — already enforced for h264/h265.
expect(args[args.indexOf("-video_track_timescale") + 1]).toBe("90000");
});
it("true appends closed-GOP x265-params keyint controls for libx265", () => {
const args = buildEncoderArgs(
{
...baseOptions,
codec: "h265",
preset: "medium",
quality: 23,
lockGopForChunkConcat: true,
gopSize: 360,
},
inputArgs,
"out.mp4",
);
expect(args[args.indexOf("-g") + 1]).toBe("360");
expect(args[args.indexOf("-keyint_min") + 1]).toBe("360");
expect(args[args.indexOf("-sc_threshold") + 1]).toBe("0");
expect(args[args.indexOf("-force_key_frames") + 1]).toBe("expr:eq(mod(n,360),0)");
const paramIdx = args.indexOf("-x265-params");
expect(args[paramIdx + 1]).toContain("keyint=360");
expect(args[paramIdx + 1]).toContain("min-keyint=360");
expect(args[paramIdx + 1]).toContain("scenecut=0");
expect(args[paramIdx + 1]).toContain("open-gop=0");
expect(args[paramIdx + 1]).toContain("repeat-headers=1");
// h265 normally tolerates B-frames; closed-GOP concat-copy doesn't.
expect(args[args.indexOf("-bf") + 1]).toBe("0");
});
it("true preserves the x264-params anti-banding controls", () => {
// The closed-GOP params join onto the existing aq-mode/deblock string —
// make sure we didn't accidentally drop the anti-banding tuning.
const args = buildEncoderArgs(
{
...baseOptions,
codec: "h264",
preset: "medium",
quality: 23,
lockGopForChunkConcat: true,
gopSize: 240,
},
inputArgs,
"out.mp4",
);
const paramIdx = args.indexOf("-x264-params");
expect(args[paramIdx + 1]).toContain("aq-mode=3");
expect(args[paramIdx + 1]).toContain("aq-strength=0.8");
expect(args[paramIdx + 1]).toContain("deblock=1,1");
expect(args[paramIdx + 1]).toContain("colorprim=bt709");
});
it("true with ultrafast preset still emits closed-GOP params and skips deblock", () => {
const args = buildEncoderArgs(
{
...baseOptions,
codec: "h264",
preset: "ultrafast",
quality: 28,
lockGopForChunkConcat: true,
gopSize: 240,
},
inputArgs,
"out.mp4",
);
expect(args[args.indexOf("-g") + 1]).toBe("240");
const paramIdx = args.indexOf("-x264-params");
expect(args[paramIdx + 1]).toContain("aq-mode=3");
expect(args[paramIdx + 1]).toContain("scenecut=0");
expect(args[paramIdx + 1]).not.toContain("deblock");
});
it("true is a no-op on GPU encoders", () => {
// GPU encoders take a separate code path; lockGopForChunkConcat does not
// wire `-g` / `-keyint_min` into nvenc/qsv/vaapi.
const args = buildEncoderArgs(
{
...baseOptions,
codec: "h264",
preset: "medium",
quality: 23,
useGpu: true,
lockGopForChunkConcat: true,
gopSize: 240,
},
inputArgs,
"out.mp4",
"nvenc",
);
expect(args).not.toContain("-g");
expect(args).not.toContain("-keyint_min");
expect(args).not.toContain("-force_key_frames");
expect(args).not.toContain("-sc_threshold");
expect(args.indexOf("-x264-params")).toBe(-1);
});
it("true is a no-op on VP9", () => {
const args = buildEncoderArgs(
{
...baseOptions,
codec: "vp9",
preset: "good",
quality: 23,
lockGopForChunkConcat: true,
gopSize: 240,
},
inputArgs,
"out.webm",
);
expect(args).not.toContain("-g");
expect(args).not.toContain("-keyint_min");
expect(args).not.toContain("-force_key_frames");
});
it("true is a no-op on ProRes (intra-only — no GOP forcing needed)", () => {
const args = buildEncoderArgs(
{
...baseOptions,
codec: "prores",
preset: "4444",
quality: 23,
lockGopForChunkConcat: true,
gopSize: 240,
},
inputArgs,
"out.mov",
);
expect(args).not.toContain("-g");
expect(args).not.toContain("-keyint_min");
expect(args).not.toContain("-force_key_frames");
});
it("true with missing or invalid gopSize throws", () => {
for (const bad of [undefined, 0, -10, NaN, Infinity]) {
expect(() =>
buildEncoderArgs(
{
...baseOptions,
codec: "h264",
preset: "medium",
quality: 23,
lockGopForChunkConcat: true,
gopSize: bad as number | undefined,
},
inputArgs,
"out.mp4",
),
).toThrow(/lockGopForChunkConcat=true requires a positive integer gopSize/);
}
});
it("HDR + closed-GOP keeps HDR mastering metadata in x265-params", () => {
const args = buildEncoderArgs(
{
...baseOptions,
codec: "h265",
preset: "medium",
quality: 23,
hdr: { transfer: "pq" },
lockGopForChunkConcat: true,
gopSize: 240,
},
inputArgs,
"out.mp4",
);
const paramIdx = args.indexOf("-x265-params");
expect(args[paramIdx + 1]).toContain("colorprim=bt2020");
expect(args[paramIdx + 1]).toContain("transfer=smpte2084");
expect(args[paramIdx + 1]).toContain("master-display=");
expect(args[paramIdx + 1]).toContain("max-cll=");
expect(args[paramIdx + 1]).toContain("keyint=240");
expect(args[paramIdx + 1]).toContain("scenecut=0");
});
});
describe("buildEncoderArgs HDR color space", () => {
const baseOptions = { fps: { num: 30, den: 1 }, width: 1920, height: 1080 };
const inputArgs = ["-framerate", "30", "-i", "frames/%04d.png"];
+56 -3
View File
@@ -165,6 +165,36 @@ export function buildEncoderArgs(
if (bitrate) args.push("-b:v", bitrate);
else args.push("-crf", String(quality));
// Closed-GOP / forced-keyframe args so an external orchestrator can
// ffmpeg-concat chunk files with `-c copy`. Without these, libx264 /
// libx265 emit open-GOP frames with mid-chunk scenecut keyframes; the
// first frame of each chunk isn't an independently-decodable IDR and
// concat-copy playback freezes at chunk seams on some decoders.
const lockGop = options.lockGopForChunkConcat === true;
let gop = 0;
if (lockGop) {
if (
typeof options.gopSize !== "number" ||
!Number.isFinite(options.gopSize) ||
options.gopSize <= 0
) {
throw new Error(
`[chunkEncoder] lockGopForChunkConcat=true requires a positive integer gopSize (received ${String(options.gopSize)})`,
);
}
gop = Math.floor(options.gopSize);
args.push(
"-g",
String(gop),
"-keyint_min",
String(gop),
"-sc_threshold",
"0",
"-force_key_frames",
`expr:eq(mod(n,${gop}),0)`,
);
}
// Disable B-frames. Standard h264 with B-frames produces negative DTS
// at the start of the stream (the first B-frame's decode order is
// "before" the first I-frame's presentation time). VS Code's video
@@ -173,7 +203,12 @@ export function buildEncoderArgs(
// -bf 0 makes PTS == DTS at every frame, eliminating the issue at the
// source. Quality cost is ~5–10% larger files at the same CRF — a
// worthwhile trade for "the file plays everywhere".
if (codec === "h264") {
//
// Also emit `-bf 0` for h265 when closed-GOP is locked: chunked
// concat-copy of h265 with B-frames hits the same negative-DTS hazard
// at every chunk boundary, even though single-stream h265 normally
// tolerates B-frames fine.
if (codec === "h264" || (codec === "h265" && lockGop)) {
args.push("-bf", "0");
}
@@ -182,15 +217,33 @@ export function buildEncoderArgs(
// For HDR x265 paths we additionally embed BT.2020 + transfer + HDR static
// mastering metadata via x265-params; libx264 only carries BT.709 tags
// since HDR through H.264 is not supported by this encoder path.
//
// When closed-GOP is locked we additionally bake the keyint/scenecut
// controls into the codec param string so libx264's slice-type decisions
// and libx265's rate-control respect the IDR cadence end-to-end (without
// these, ffmpeg's `-force_key_frames` is honored but the underlying
// encoder may still insert mini-GOPs with open-GOP references that
// break concat-copy on some decoders). `repeat-headers=1` writes SPS/PPS
// at every keyframe so each chunk file is self-contained.
const xParamsFlag = codec === "h264" ? "-x264-params" : "-x265-params";
const colorParams =
codec === "h265" && options.hdr
? getHdrEncoderColorParams(options.hdr.transfer).x265ColorParams
: "colorprim=bt709:transfer=bt709:colormatrix=bt709";
let gopParams = "";
if (lockGop) {
const shared = "scenecut=0:open-gop=0:repeat-headers=1";
gopParams = codec === "h264" ? shared : `keyint=${gop}:min-keyint=${gop}:${shared}`;
}
const joinParams = (...parts: string[]): string =>
parts.filter((p) => p.length > 0).join(":");
if (preset === "ultrafast") {
args.push(xParamsFlag, `aq-mode=3:${colorParams}`);
args.push(xParamsFlag, joinParams("aq-mode=3", colorParams, gopParams));
} else {
args.push(xParamsFlag, `aq-mode=3:aq-strength=0.8:deblock=1,1:${colorParams}`);
args.push(
xParamsFlag,
joinParams("aq-mode=3", "aq-strength=0.8", "deblock=1,1", colorParams, gopParams),
);
}
}
// Apple devices require hvc1 tag for HEVC playback (default hev1 won't open in QuickTime)
@@ -13,6 +13,25 @@ export interface EncoderOptions {
pixelFormat?: string;
useGpu?: boolean;
hdr?: { transfer: HdrTransfer };
/**
* When `true`, force closed-GOP encoding with a keyframe at every
* `gopSize` boundary so the resulting chunk file can be losslessly
* concatenated (`ffmpeg -f concat -c copy`) with sibling chunks.
*
* Default `false`: GOP placement is left to libx264/libx265 defaults
* (open-GOP, scenecut-driven keyframes), preserving the in-process
* renderer's byte-identical output.
*
* Only honored by the SW libx264 / libx265 paths. GPU encoders, vp9, and
* prores ignore the flag (their concat-copy story is separate).
*/
lockGopForChunkConcat?: boolean;
/**
* Required when `lockGopForChunkConcat` is `true`. Number of frames per
* GOP — set to `chunkSize` so every chunk starts on an IDR keyframe and
* concat-copy boundaries land on independently-decodable frames.
*/
gopSize?: number;
}
export interface EncodeResult {
@@ -0,0 +1,183 @@
/**
* Tests for `discardWarmupCapture` — the helper distributed chunk workers
* run before their first real capture to prime `lastFrameCache`.
*
* The helper is a thin wrapper around the inner `captureFrameCore`
* machinery, so its testable contract is post-conditional rather than
* pixel-level:
*
* 1. The wrapper invokes the inner capture exactly once with the supplied
* `(frameIndex, time)`.
* 2. After the wrapper returns, the session's perf and BeginFrame damage
* counters look exactly as they did before — even though the inner
* capture mutated them.
* 3. The wrapper writes no file to disk (no path is plumbed through;
* asserted indirectly by observing that `outputDir` is never read).
* 4. State is restored even if the inner capture throws.
*
* The inner capture is stubbed (the helper accepts an injectable
* `innerCapture` for exactly this reason). We don't need a real Chrome.
*/
import { describe, expect, it } from "vitest";
import { existsSync, readdirSync, mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { discardWarmupCapture, type CaptureSession } from "./frameCapture.js";
function makeFakeSession(): CaptureSession {
// The discardWarmupCapture wrapper only reads `capturePerf`,
// `beginFrameHasDamageCount`, `beginFrameNoDamageCount`. Everything else
// is unused — leave it as bare-minimum stubs cast through `unknown`.
return {
browser: {} as unknown,
page: {} as unknown,
options: { width: 1, height: 1, fps: { num: 30, den: 1 } },
serverUrl: "",
outputDir: mkdtempSync(join(tmpdir(), "discard-warmup-")),
onBeforeCapture: null,
isInitialized: true,
browserConsoleBuffer: [],
capturePerf: {
frames: 7,
seekMs: 100,
beforeCaptureMs: 50,
screenshotMs: 200,
totalMs: 350,
},
captureMode: "beginframe",
beginFrameTimeTicks: 0,
beginFrameIntervalMs: 33,
beginFrameHasDamageCount: 4,
beginFrameNoDamageCount: 3,
} as unknown as CaptureSession;
}
describe("discardWarmupCapture", () => {
it("calls the inner capture exactly once with (frameIndex=0, time=0) by default", async () => {
const session = makeFakeSession();
try {
let calls = 0;
let receivedFrameIndex = -1;
let receivedTime = -1;
await discardWarmupCapture(session, undefined, undefined, async (_s, fi, t) => {
calls++;
receivedFrameIndex = fi;
receivedTime = t;
return { buffer: Buffer.alloc(0), quantizedTime: t, captureTimeMs: 0 };
});
expect(calls).toBe(1);
expect(receivedFrameIndex).toBe(0);
expect(receivedTime).toBe(0);
} finally {
rmSync(session.outputDir, { recursive: true, force: true });
}
});
it("passes through caller-supplied (frameIndex, time)", async () => {
const session = makeFakeSession();
try {
let received: { fi: number; t: number } | null = null;
await discardWarmupCapture(session, 240, 8, async (_s, fi, t) => {
received = { fi, t };
return { buffer: Buffer.alloc(0), quantizedTime: t, captureTimeMs: 0 };
});
expect(received).toEqual({ fi: 240, t: 8 });
} finally {
rmSync(session.outputDir, { recursive: true, force: true });
}
});
it("restores perf counters after the inner capture mutates them", async () => {
const session = makeFakeSession();
const before = { ...session.capturePerf };
try {
await discardWarmupCapture(session, 0, 0, async (s) => {
s.capturePerf.frames += 1;
s.capturePerf.seekMs += 12;
s.capturePerf.beforeCaptureMs += 5;
s.capturePerf.screenshotMs += 33;
s.capturePerf.totalMs += 50;
return { buffer: Buffer.alloc(0), quantizedTime: 0, captureTimeMs: 50 };
});
expect(session.capturePerf).toEqual(before);
} finally {
rmSync(session.outputDir, { recursive: true, force: true });
}
});
it("restores BeginFrame damage counters after the inner capture mutates them", async () => {
const session = makeFakeSession();
const hasBefore = session.beginFrameHasDamageCount;
const noBefore = session.beginFrameNoDamageCount;
try {
await discardWarmupCapture(session, 0, 0, async (s) => {
s.beginFrameHasDamageCount += 10;
s.beginFrameNoDamageCount += 1;
return { buffer: Buffer.alloc(0), quantizedTime: 0, captureTimeMs: 0 };
});
expect(session.beginFrameHasDamageCount).toBe(hasBefore);
expect(session.beginFrameNoDamageCount).toBe(noBefore);
} finally {
rmSync(session.outputDir, { recursive: true, force: true });
}
});
it("restores state even when the inner capture throws", async () => {
const session = makeFakeSession();
const perfBefore = { ...session.capturePerf };
const hasBefore = session.beginFrameHasDamageCount;
const noBefore = session.beginFrameNoDamageCount;
try {
let thrown: unknown;
try {
await discardWarmupCapture(session, 0, 0, async (s) => {
s.capturePerf.frames += 5;
s.beginFrameNoDamageCount += 2;
throw new Error("simulated capture failure");
});
} catch (err) {
thrown = err;
}
expect((thrown as Error).message).toBe("simulated capture failure");
// The whole point of `finally { restore }`: failure must not leak
// inflated counters into the real capture summary.
expect(session.capturePerf).toEqual(perfBefore);
expect(session.beginFrameHasDamageCount).toBe(hasBefore);
expect(session.beginFrameNoDamageCount).toBe(noBefore);
} finally {
rmSync(session.outputDir, { recursive: true, force: true });
}
});
it("writes no output file to the session's outputDir", async () => {
const session = makeFakeSession();
try {
expect(existsSync(session.outputDir)).toBe(true);
const before = readdirSync(session.outputDir);
await discardWarmupCapture(session, 0, 0, async () => ({
buffer: Buffer.from([0xff, 0xff, 0xff]),
quantizedTime: 0,
captureTimeMs: 0,
}));
const after = readdirSync(session.outputDir);
expect(after).toEqual(before);
} finally {
rmSync(session.outputDir, { recursive: true, force: true });
}
});
it("returns undefined (no result type, so the buffer can't escape)", async () => {
const session = makeFakeSession();
try {
const result = await discardWarmupCapture(session, 0, 0, async () => ({
buffer: Buffer.from([0x01]),
quantizedTime: 0,
captureTimeMs: 1,
}));
expect(result).toBeUndefined();
} finally {
rmSync(session.outputDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,174 @@
/**
* Tests for the `driveWarmupTicks` BeginFrame warmup driver.
*
* The wall-clock warmup loop in `initializeSession` accumulates a different
* number of ticks per host CPU speed, which shifts `beginFrameTimeTicks`
* and breaks byte-identical captures across distributed workers.
*
* `lockWarmupTicks=true` clamps the loop to a fixed iteration count
* (`LOCKED_WARMUP_TICKS = 60`) so the baseline becomes host-independent.
*/
import { describe, expect, it } from "vitest";
import {
LOCKED_WARMUP_TICKS,
driveWarmupTicks,
warmupFrameTimeTicks,
type WarmupTickState,
} from "./frameCapture.js";
function makeState(): WarmupTickState {
return { running: true, ticks: 0 };
}
// Run the warmup driver "concurrently" with a simulated page-load that
// flips `state.running` to false after `pageLoadIterations` of the
// `tick` callback. Returns the final state.
async function runWithSimulatedPageLoad(
lockWarmupTicks: boolean,
pageLoadIterations: number,
tickDelay: () => Promise<void> = () => Promise.resolve(),
): Promise<WarmupTickState> {
const state = makeState();
const intervalMs = 33;
const tick = async (): Promise<void> => {
if (state.ticks + 1 === pageLoadIterations) {
state.running = false;
}
await tickDelay();
};
await driveWarmupTicks(
{
intervalMs,
lockWarmupTicks,
tick,
sleep: () => Promise.resolve(),
},
state,
);
return state;
}
describe("driveWarmupTicks — unlocked", () => {
it("stops when state.running flips false", async () => {
const state = await runWithSimulatedPageLoad(false, 10);
expect(state.ticks).toBe(10);
});
it("yields different tick counts for different simulated page-load lengths", async () => {
const fast = await runWithSimulatedPageLoad(false, 5);
const slow = await runWithSimulatedPageLoad(false, 50);
expect(fast.ticks).toBe(5);
expect(slow.ticks).toBe(50);
expect(fast.ticks).not.toBe(slow.ticks);
});
it("frame time derived as ticks * intervalMs", async () => {
const state = await runWithSimulatedPageLoad(false, 7);
expect(warmupFrameTimeTicks(state, 33)).toBe(7 * 33);
});
it("does not iterate when state.running starts false", async () => {
const state: WarmupTickState = { running: false, ticks: 0 };
await driveWarmupTicks(
{
intervalMs: 33,
lockWarmupTicks: false,
tick: async () => {},
sleep: () => Promise.resolve(),
},
state,
);
expect(state.ticks).toBe(0);
});
it("counts ticks even when tick callback throws (page not ready yet)", async () => {
// Pre-acquisition errors are swallowed — the loop must keep spinning so
// it can drive the page once CDP comes up.
let stopAt = 5;
const state = makeState();
await driveWarmupTicks(
{
intervalMs: 33,
lockWarmupTicks: false,
tick: async () => {
if (--stopAt <= 0) state.running = false;
throw new Error("CDP not ready");
},
sleep: () => Promise.resolve(),
},
state,
);
// We don't count ticks on throw, but the loop kept running until
// state.running flipped — so it exited cleanly.
expect(state.running).toBe(false);
expect(state.ticks).toBe(0);
});
});
describe("driveWarmupTicks — locked", () => {
it("runs exactly LOCKED_WARMUP_TICKS iterations regardless of state.running", async () => {
// Simulate a fast page load: state.running flips false after just 5 ticks.
// The locked loop must still run to LOCKED_WARMUP_TICKS.
const state = await runWithSimulatedPageLoad(true, 5);
expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
});
it("runs exactly LOCKED_WARMUP_TICKS iterations on slow simulated load", async () => {
// state.running stays true past the locked count — loop still stops at
// LOCKED_WARMUP_TICKS.
const state = await runWithSimulatedPageLoad(true, 9999);
expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
});
it("yields IDENTICAL tick counts across simulated fast and slow loads", async () => {
// THE determinism property the locked mode exists for.
const fast = await runWithSimulatedPageLoad(true, 5);
const slow = await runWithSimulatedPageLoad(true, 200);
expect(fast.ticks).toBe(slow.ticks);
expect(fast.ticks).toBe(LOCKED_WARMUP_TICKS);
expect(warmupFrameTimeTicks(fast, 33)).toBe(warmupFrameTimeTicks(slow, 33));
});
it("yields LOCKED_WARMUP_TICKS even when state.running starts false", async () => {
// Guards the locked contract independently of the caller's running flag.
const state: WarmupTickState = { running: false, ticks: 0 };
await driveWarmupTicks(
{
intervalMs: 33,
lockWarmupTicks: true,
tick: async () => {},
sleep: () => Promise.resolve(),
},
state,
);
expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
});
it("does not exceed LOCKED_WARMUP_TICKS even if the caller never stops it", async () => {
const state = makeState();
let observedMax = 0;
await driveWarmupTicks(
{
intervalMs: 33,
lockWarmupTicks: true,
tick: async () => {
observedMax = Math.max(observedMax, state.ticks + 1);
},
sleep: () => Promise.resolve(),
},
state,
);
expect(observedMax).toBe(LOCKED_WARMUP_TICKS);
expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
});
it("baseline frame time matches (LOCKED_WARMUP_TICKS * intervalMs)", async () => {
// initializeSession derives session.beginFrameTimeTicks from the final
// tick count — locked mode pins this to a host-independent value.
const state = await runWithSimulatedPageLoad(true, 5);
expect(warmupFrameTimeTicks(state, 33)).toBe(LOCKED_WARMUP_TICKS * 33);
});
});
+184 -24
View File
@@ -78,6 +78,81 @@ export interface CaptureSession {
const BROWSER_CONSOLE_BUFFER_SIZE = 200;
const CAPTURE_SESSION_CLOSE_TIMEOUT_MS = 5_000;
/**
* Fixed warmup-loop iteration count used when `CaptureOptions.lockWarmupTicks`
* is `true`. Picked to roughly match the median tick count observed by the
* unlocked wall-clock loop during a typical 2s page load at 30fps — so
* `beginFrameTimeTicks` lands in a similar range regardless of host speed.
*/
export const LOCKED_WARMUP_TICKS = 60;
/**
* Internal driver for the BeginFrame warmup loop.
*
* - Unlocked: exits as soon as `state.running` flips to `false`. Tick count
* varies with wall-clock page-load time.
* - Locked: ignores `state.running` entirely and exits once it has driven
* exactly `LOCKED_WARMUP_TICKS` iterations. Caller awaits this promise
* after page-readiness so `session.beginFrameTimeTicks` is identical
* across hosts.
* - `tick` errors are swallowed (Chrome's `beginFrame` is best-effort
* during page load — the page hasn't installed CDP listeners yet). When
* `tick` throws, the iteration count does NOT advance.
*
* `intervalMs` is the BeginFrame interval (≈33ms at 30fps).
*
* `frameTimeTicks` is derived as `ticks * intervalMs` and exposed via
* {@link warmupFrameTimeTicks} — not stored on the state, to keep `ticks`
* the single source of truth.
*/
export interface WarmupTickState {
running: boolean;
ticks: number;
}
export interface WarmupTickOptions {
intervalMs: number;
lockWarmupTicks: boolean;
tick: (frameTimeTicks: number, intervalMs: number) => Promise<void>;
/** Injectable so tests can advance "time" without real setTimeout. */
sleep?: (ms: number) => Promise<void>;
}
const realSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Derive the current simulated frame time from a warmup state. Single source
* of truth so tests and callers stay in sync.
*/
export function warmupFrameTimeTicks(state: WarmupTickState, intervalMs: number): number {
return state.ticks * intervalMs;
}
export async function driveWarmupTicks(
options: WarmupTickOptions,
state: WarmupTickState,
): Promise<void> {
const sleep = options.sleep ?? realSleep;
while (true) {
if (options.lockWarmupTicks) {
// Locked mode exits on the iteration count, ignoring `state.running` —
// the caller flips `running=false` after page-readiness but we keep
// ticking until LOCKED_WARMUP_TICKS so the count is host-independent.
if (state.ticks >= LOCKED_WARMUP_TICKS) return;
} else {
// Unlocked mode is wall-clock-bounded.
if (!state.running) return;
}
try {
await options.tick(state.ticks * options.intervalMs, options.intervalMs);
state.ticks += 1;
} catch {
// Page not ready yet; keep spinning.
}
await sleep(options.intervalMs);
}
}
async function waitForCloseWithTimeout(promise: Promise<unknown>): Promise<boolean> {
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -447,38 +522,53 @@ export async function initializeSession(session: CaptureSession): Promise<void>
// In BeginFrame mode, Chrome's event loop is paused until we issue frames.
// Start a warmup loop to drive rAF/setTimeout callbacks during page load.
let warmupRunning = true;
let warmupTicks = 0;
let warmupFrameTime = 0;
//
// The unlocked path runs while `warmupState.running` stays true — wall-
// clock-bounded. The locked path (`options.lockWarmupTicks`) additionally
// exits at exactly `LOCKED_WARMUP_TICKS` iterations so `beginFrameTimeTicks`
// is deterministic across hosts with different page-load latencies.
const warmupIntervalMs = 33; // ~30fps
const warmupState: WarmupTickState = {
running: true,
ticks: 0,
};
const lockWarmupTicks = session.options.lockWarmupTicks === true;
let warmupClient: import("puppeteer-core").CDPSession | null = null;
const warmupLoop = async () => {
const acquireWarmupClient = async (): Promise<void> => {
try {
warmupClient = await getCdpSession(page);
await warmupClient.send("HeadlessExperimental.enable");
} catch {
/* page not ready yet */
}
};
while (warmupRunning) {
if (warmupClient) {
try {
const warmupLoopPromise = (async () => {
await acquireWarmupClient();
await driveWarmupTicks(
{
intervalMs: warmupIntervalMs,
lockWarmupTicks,
tick: async (frameTimeTicks, interval) => {
if (!warmupClient) {
// No CDP yet — let driveWarmupTicks count the tick anyway so the
// locked iteration count is reached deterministically. Throwing
// would skip the ticks++ increment, leaking host-load variance
// back into the count.
return;
}
await warmupClient.send("HeadlessExperimental.beginFrame", {
frameTimeTicks: warmupFrameTime,
interval: warmupIntervalMs,
frameTimeTicks,
interval,
noDisplayUpdates: true,
});
warmupFrameTime += warmupIntervalMs;
warmupTicks++;
} catch {
/* ignore warmup errors */
}
}
await new Promise((r) => setTimeout(r, warmupIntervalMs));
}
};
warmupLoop().catch(() => {});
},
},
warmupState,
);
})();
warmupLoopPromise.catch(() => {});
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
@@ -497,7 +587,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
`!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`,
);
if (!pageReady) {
warmupRunning = false;
warmupState.running = false;
throw new Error(
`[FrameCapture] window.__hf not ready after ${pageReadyTimeout}ms. Page must expose window.__hf = { duration, seek }.`,
);
@@ -521,11 +611,18 @@ export async function initializeSession(session: CaptureSession): Promise<void>
await page.evaluate(`document.fonts?.ready`);
await waitForOptionalTailwindReady(page, pageReadyTimeout);
// Stop warmup
warmupRunning = false;
// Stop warmup. Unlocked mode exits on this flag; locked mode keeps ticking
// until LOCKED_WARMUP_TICKS, so we await its promise to ensure the count is
// exact before deriving the baseline.
warmupState.running = false;
if (lockWarmupTicks) {
await warmupLoopPromise.catch(() => {});
}
// Set base frame time ticks past warmup range
session.beginFrameTimeTicks = (warmupTicks + 10) * session.beginFrameIntervalMs;
// Set base frame time ticks past warmup range. Locked mode pins to the
// constant so chunk workers on different hosts compute the same baseline.
const baseTickCount = lockWarmupTicks ? LOCKED_WARMUP_TICKS : warmupState.ticks;
session.beginFrameTimeTicks = (baseTickCount + 10) * session.beginFrameIntervalMs;
// For PNG captures, inject the transparent-background override + stylesheet
// (see the screenshot-mode branch above for the rationale). BeginFrame mode
@@ -712,6 +809,69 @@ export async function captureFrameToBuffer(
return { buffer, captureTimeMs };
}
/**
* Type of the "inner capture" function consumed by
* {@link discardWarmupCapture}. Matches the real `captureFrameCore` signature
* with the buffer-bearing result trimmed to what the caller actually uses
* (the wrapper never inspects the buffer). Exposed so unit tests can inject
* a stub instead of driving Chrome end-to-end.
*/
export type DiscardWarmupInnerCapture = (
session: CaptureSession,
frameIndex: number,
time: number,
) => Promise<{ buffer: Buffer; quantizedTime: number; captureTimeMs: number }>;
/**
* Perform one capture, throw away the buffer, and restore any session
* side-effects (perf counters, BeginFrame damage tallies) so downstream
* captures see state identical to a fresh session.
*
* Distributed chunk workers need this because Chrome's BeginFrame screenshot
* pipeline maintains a per-process `lastFrameCache`: when a captured frame's
* `hasDamage` reports `false`, the screenshot path returns the previously
* captured buffer. For chunk N (N > 0) the worker has no prior frame in its
* cache, so the very first capture's `hasDamage` reporting diverges from
* what an in-process render at the same absolute frame index would see (the
* in-process renderer always has frame N-1 cached). One discard capture
* before the first real capture primes the cache.
*
* The function intentionally restores perf state so the warmup capture does
* NOT bias `getCapturePerfSummary()`'s per-frame averages.
*
* No file is written; the buffer is discarded.
*
* @param session — initialized capture session
* @param frameIndex — frame index to warm up with (default 0). Chunk
* workers typically pass their chunk's first absolute frame index.
* @param time — time in seconds (default 0). Chunk workers typically pass
* the corresponding `frameIndex / fps`.
* @param innerCapture — injectable for tests; defaults to the real
* `captureFrameCore`.
*/
export async function discardWarmupCapture(
session: CaptureSession,
frameIndex: number = 0,
time: number = 0,
innerCapture: DiscardWarmupInnerCapture = captureFrameCore,
): Promise<void> {
// Snapshot the side-effect counters captureFrameCore mutates. We use a
// shallow `{...}` for capturePerf because all five fields are primitive
// numbers — no nested state to deep-copy.
const perfBefore = { ...session.capturePerf };
const hasDamageBefore = session.beginFrameHasDamageCount;
const noDamageBefore = session.beginFrameNoDamageCount;
try {
await innerCapture(session, frameIndex, time);
} finally {
// Always restore — even on error. A failed warmup capture should not
// leak inflated perf counters into the real capture summary.
session.capturePerf = perfBefore;
session.beginFrameHasDamageCount = hasDamageBefore;
session.beginFrameNoDamageCount = noDamageBefore;
}
}
export async function closeCaptureSession(session: CaptureSession): Promise<void> {
// INVARIANT: closeCaptureSession is idempotent. The renderOrchestrator HDR
// cleanup path tracks a `domSessionClosed` flag and may still re-call this
@@ -24,7 +24,6 @@ import {
} from "../utils/gpuEncoder.js";
import { formatFfmpegError } from "../utils/runFfmpeg.js";
import { getHdrEncoderColorParams } from "../utils/hdr.js";
import { type EncoderOptions } from "./chunkEncoder.types.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { fpsToFfmpegArg, type Fps } from "@hyperframes/core";
@@ -400,6 +399,9 @@ export async function spawnStreamingEncoder(
exitPromiseResolve?.();
});
ffmpeg.stdin?.on("error", () => {});
ffmpeg.stdout?.on("error", () => {});
// Handle abort signal
const onAbort = () => {
if (exitStatus === "running") {
+14
View File
@@ -120,6 +120,20 @@ export interface CaptureOptions {
* `--variables-file <path>`. Must be a JSON-serializable plain object.
*/
variables?: Record<string, unknown>;
/**
* When `true`, the BeginFrame warmup loop driven during page navigation
* runs exactly `LOCKED_WARMUP_TICKS` (60) iterations regardless of how
* long the page load takes, making `session.beginFrameTimeTicks`
* deterministic across machines with different page-load latencies.
*
* Default `false`: wall-clock-bounded driver — ticks until page-readiness
* completes, accumulating whatever count the host CPU manages. Preserves
* the in-process renderer's BeginFrame timing baselines.
*
* Has no effect outside BeginFrame mode (screenshot capture never runs a
* warmup loop).
*/
lockWarmupTicks?: boolean;
}
export interface CaptureVideoMetadataHint {
@@ -0,0 +1,130 @@
/**
* Tests for assertSwiftShader and its companion readWebGlVendorInfo helper.
*
* We don't spin up a real Chrome here — the assertion's contract is "given a
* WebGL info pair, accept SwiftShader and reject anything else." Tests inject
* the info pair through the optional `readInfo` override that the
* production code path leaves as a default.
*/
import { describe, expect, it } from "vitest";
import type { Page } from "puppeteer-core";
import {
BROWSER_GPU_NOT_SOFTWARE,
SwiftShaderAssertionError,
assertSwiftShader,
} from "./assertSwiftShader.js";
// Minimal Page stub. Only assertSwiftShader's default `readInfo` ever touches
// `page.goto` / `page.evaluate`; when we inject a custom `readInfo` the page
// object is never used, so an empty cast is safe.
const stubPage = {} as unknown as Page;
describe("assertSwiftShader", () => {
it("accepts the canonical SwiftShader vendor + renderer pair", async () => {
await assertSwiftShader(stubPage, async () => ({
vendor: "Google Inc. (Google)",
renderer:
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
}));
});
it("accepts SwiftShader regardless of trailing whitespace on vendor", async () => {
await assertSwiftShader(stubPage, async () => ({
vendor: " Google Inc. (Google) ",
renderer: "SwiftShader",
}));
});
it("accepts case-insensitive renderer token", async () => {
await assertSwiftShader(stubPage, async () => ({
vendor: "Google Inc. (Google)",
renderer: "ANGLE (Google, swiftshader Device, swiftshader driver)",
}));
});
it("throws SwiftShaderAssertionError when vendor is hardware-accelerated", async () => {
let caught: unknown;
try {
await assertSwiftShader(stubPage, async () => ({
vendor: "Google Inc. (NVIDIA Corporation)",
renderer: "ANGLE (NVIDIA, NVIDIA GeForce RTX 4090, OpenGL 4.6)",
}));
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SwiftShaderAssertionError);
expect((caught as SwiftShaderAssertionError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
expect((caught as Error).message).toContain("non-SwiftShader");
expect((caught as Error).message).toContain("--use-gl=swiftshader");
expect((caught as SwiftShaderAssertionError).vendor).toBe("Google Inc. (NVIDIA Corporation)");
});
it("throws when the renderer string lacks SwiftShader even if vendor matches", async () => {
// Google Inc. is the umbrella vendor for many ANGLE backends — vendor
// alone is not enough; the renderer must actually mention SwiftShader.
let caught: unknown;
try {
await assertSwiftShader(stubPage, async () => ({
vendor: "Google Inc. (Google)",
renderer: "ANGLE (Google, Vulkan 1.3.0 (Intel(R) UHD Graphics 630), OpenGL ES 3.0)",
}));
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SwiftShaderAssertionError);
expect((caught as SwiftShaderAssertionError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
});
it("throws when both vendor and renderer are empty", async () => {
// Some chrome:// pages return empty strings before the GPU info table
// populates. We treat that as failure rather than silently passing.
let caught: unknown;
try {
await assertSwiftShader(stubPage, async () => ({ vendor: "", renderer: "" }));
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SwiftShaderAssertionError);
expect((caught as SwiftShaderAssertionError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
});
it("propagates errors from the info reader without wrapping", async () => {
const upstream = new Error("simulated CDP failure");
let caught: unknown;
try {
await assertSwiftShader(stubPage, async () => {
throw upstream;
});
} catch (err) {
caught = err;
}
// Reader errors should not be masked by SwiftShaderAssertionError —
// they are a separate failure class (probably retryable).
expect(caught).toBe(upstream);
});
it("rejects an unrelated vendor that happens to contain the SwiftShader token in the renderer", async () => {
// Defensive: if some future ANGLE build uses a non-Google vendor string
// but still mentions SwiftShader in the renderer for some reason, we
// still want to require the exact Google vendor signature.
let caught: unknown;
try {
await assertSwiftShader(stubPage, async () => ({
vendor: "Mesa/X.org",
renderer: "llvmpipe (SwiftShader compatible)",
}));
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SwiftShaderAssertionError);
});
});
describe("SwiftShaderAssertionError", () => {
it("exposes the BROWSER_GPU_NOT_SOFTWARE typed-failure code", () => {
const err = new SwiftShaderAssertionError("test", "v", "r");
expect(err.code).toBe(BROWSER_GPU_NOT_SOFTWARE);
expect(err.code).toBe("BROWSER_GPU_NOT_SOFTWARE");
});
});
@@ -0,0 +1,126 @@
/**
* assertSwiftShader — verify Chrome's WebGL is rendered by SwiftShader.
*
* Distributed renders pixel-lock on the GPU backend: hardware GL is bitwise
* unstable across worker machines (different drivers, driver versions, GL
* extension sets, even differing fp32 rounding on the same vendor). Chunk
* workers launch Chrome with `--use-gl=swiftshader --use-angle=swiftshader`
* so every worker uses the same pure-software GL implementation.
*
* Those Chrome flags are advisory: a misconfigured base image, a missing
* SwiftShader library, or a `chrome://gpu` blocklist override can silently
* downgrade to system GL. The distributed pipeline cannot detect the
* downgrade by sampling pixels (one machine = one render), so we read
* `chrome://gpu` directly after launch and refuse to render if the active
* GL renderer is anything other than SwiftShader.
*/
import type { Page } from "puppeteer-core";
/**
* Error code classifying this failure as non-retryable for distributed
* workflow adapters — a downgraded GPU on a worker will not heal on retry.
*/
export const BROWSER_GPU_NOT_SOFTWARE = "BROWSER_GPU_NOT_SOFTWARE";
/**
* Error thrown when chrome://gpu reports a non-SwiftShader WebGL backend.
*
* Carries a `code` property so the adapter can match on it without parsing
* the message string — Temporal/Step Functions retry policies key off the
* code, not the message.
*/
export class SwiftShaderAssertionError extends Error {
readonly code: typeof BROWSER_GPU_NOT_SOFTWARE = BROWSER_GPU_NOT_SOFTWARE;
readonly vendor: string;
readonly renderer: string;
constructor(message: string, vendor: string, renderer: string) {
super(message);
this.name = "SwiftShaderAssertionError";
this.vendor = vendor;
this.renderer = renderer;
}
}
/**
* SwiftShader identifies itself on `chrome://gpu` and in
* `WEBGL_debug_renderer_info` with this exact vendor string. Locking to
* Google's own GL string (rather than a substring match on "swiftshader")
* avoids false-positives from third-party ANGLE backends that incidentally
* mention SwiftShader in unrelated diagnostic text.
*/
const SWIFTSHADER_VENDOR_SIGNATURE = "Google Inc. (Google)";
/**
* Renderer string contains the literal "SwiftShader" token. We match
* case-insensitively and only require the substring; Chrome occasionally
* appends a build suffix (e.g. " Vulkan 1.3").
*/
const SWIFTSHADER_RENDERER_TOKEN = "swiftshader";
interface WebGlInfo {
vendor: string;
renderer: string;
}
/**
* Read the WebGL vendor/renderer strings from a live `chrome://gpu` page.
*
* Extracted from `assertSwiftShader` so tests can stub the navigation +
* extraction step. Returns the raw values; callers decide how to interpret
* them. Both fields are best-effort — Chrome returns empty strings if the
* GPU info table hasn't populated yet, which the caller treats as failure.
*/
export async function readWebGlVendorInfo(page: Page): Promise<WebGlInfo> {
await page.goto("chrome://gpu", { waitUntil: "domcontentloaded", timeout: 30_000 });
// The "GL_VENDOR" / "GL_RENDERER" rows live inside <info-view> shadow DOM
// in modern Chrome. We pull the structured `info_log_` payload off the
// page-level globals instead of querying the DOM, since the DOM layout has
// drifted across versions.
const info = await page.evaluate((): WebGlInfo => {
type Row = { description?: string; value?: string };
type InfoLog = { graphics_info?: { basic_info?: Row[] } };
const w = window as unknown as { browserBridge?: { gpuInfo_?: InfoLog } };
const rows: Row[] = w.browserBridge?.gpuInfo_?.graphics_info?.basic_info ?? [];
let vendor = "";
let renderer = "";
for (const row of rows) {
if (typeof row.description !== "string" || typeof row.value !== "string") continue;
if (row.description === "GL_VENDOR") vendor = row.value;
else if (row.description === "GL_RENDERER") renderer = row.value;
}
return { vendor, renderer };
});
return info;
}
/**
* Validate that the active WebGL renderer is SwiftShader. Throws
* `SwiftShaderAssertionError` otherwise.
*
* Pass an optional `readInfo` override for tests that don't have a real
* Puppeteer `Page`. The default implementation navigates to `chrome://gpu`
* and parses the GL_VENDOR / GL_RENDERER rows.
*/
export async function assertSwiftShader(
page: Page,
readInfo: (page: Page) => Promise<WebGlInfo> = readWebGlVendorInfo,
): Promise<void> {
const { vendor, renderer } = await readInfo(page);
const vendorMatches = vendor.trim() === SWIFTSHADER_VENDOR_SIGNATURE;
const rendererMatches = renderer.toLowerCase().includes(SWIFTSHADER_RENDERER_TOKEN);
if (vendorMatches && rendererMatches) return;
throw new SwiftShaderAssertionError(
`[assertSwiftShader] Chrome reported a non-SwiftShader WebGL backend. ` +
`Distributed renders require pure-software GL for pixel-identical retries. ` +
`Got vendor=${JSON.stringify(vendor)} renderer=${JSON.stringify(renderer)}; ` +
`expected vendor=${JSON.stringify(SWIFTSHADER_VENDOR_SIGNATURE)} renderer to contain "SwiftShader". ` +
`Ensure Chrome was launched with --use-gl=swiftshader --use-angle=swiftshader and that the ` +
`SwiftShader libraries are present in the runtime image.`,
vendor,
renderer,
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/player",
"version": "0.5.7",
"version": "0.6.4",
"description": "Embeddable web component for HyperFrames compositions",
"repository": {
"type": "git",
+196
View File
@@ -0,0 +1,196 @@
/**
* Probes an iframe document to discover the composition's playback adapter
* and detect whether the HyperFrames runtime needs to be injected.
*
* The probe interval polls every 200 ms until one of:
* - A `PlaybackDurationAdapter` resolves with a positive duration, or
* - 40 attempts (~8 s) expire without a result.
*
* The `CompositionProbe` class owns the interval; the caller must call
* `stop()` on disconnect or src change.
*/
import { shouldInjectRuntime } from "./shouldInjectRuntime.js";
import {
type DirectTimelineAdapter,
type PlaybackDurationAdapter,
isDirectTimelineAdapter,
isObjectRecord,
isRuntimeDurationAdapter,
} from "./timeline-adapters.js";
const RUNTIME_CDN_URL =
"https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
export interface ProbeResult {
duration: number;
adapter: PlaybackDurationAdapter;
/** Resolved composition dimensions, if present in the document. */
compositionSize: { width: number; height: number } | null;
}
export interface ProbeCallbacks {
onReady: (result: ProbeResult) => void;
onError: (message: string) => void;
/** Called when runtime is successfully injected (informational). */
onRuntimeInjected?: () => void;
}
export class CompositionProbe {
private _interval: ReturnType<typeof setInterval> | null = null;
private _runtimeInjected = false;
constructor(
private readonly _iframe: HTMLIFrameElement,
private readonly _callbacks: ProbeCallbacks,
) {}
get runtimeInjected(): boolean {
return this._runtimeInjected;
}
/** Start (or restart) the probe. Stops any previously running probe first. */
start(): void {
this.stop();
this._runtimeInjected = false;
let attempts = 0;
this._interval = setInterval(() => {
attempts++;
try {
const win = this._iframe.contentWindow as Window & {
__player?: { getDuration: () => number };
__timelines?: Record<string, { duration: () => number }>;
__hf?: unknown;
};
if (!win) return;
const hasRuntime = !!(win.__hf || win.__player);
const hasTimelines = !!(win.__timelines && Object.keys(win.__timelines).length > 0);
const hasNestedCompositions =
!!this._iframe.contentDocument?.querySelector("[data-composition-src]");
if (
shouldInjectRuntime({
hasRuntime,
hasTimelines,
hasNestedCompositions,
runtimeInjected: this._runtimeInjected,
attempts,
})
) {
this._injectRuntime();
return;
}
if (this._runtimeInjected && !hasRuntime) return;
const adapter = this._resolvePlaybackDurationAdapter(win);
if (adapter && adapter.getDuration() > 0) {
this.stop();
const doc = this._iframe.contentDocument;
let compositionSize: { width: number; height: number } | null = null;
const root = doc?.querySelector("[data-composition-id]");
if (root) {
const w = parseInt(root.getAttribute("data-width") || "0", 10);
const h = parseInt(root.getAttribute("data-height") || "0", 10);
if (w > 0 && h > 0) compositionSize = { width: w, height: h };
}
this._callbacks.onReady({
duration: adapter.getDuration(),
adapter,
compositionSize,
});
return;
}
} catch {
/* cross-origin */
}
if (attempts >= 40) {
this.stop();
this._callbacks.onError("Composition timeline not found after 8s");
}
}, 200);
}
stop(): void {
if (this._interval !== null) {
clearInterval(this._interval);
this._interval = null;
}
}
// ── Adapter resolution (same-origin only) ────────────────────────────────
resolveDirectTimelineAdapter(): DirectTimelineAdapter | null {
try {
const win = this._iframe.contentWindow;
if (!win) return null;
return this._resolveDirectTimelineAdapterFromWindow(win);
} catch {
return null;
}
}
resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null {
return this._resolveDirectTimelineAdapterFromWindow(win);
}
hasRuntimeBridge(win: Window): boolean {
return Reflect.get(win, "__hf") !== undefined || isObjectRecord(Reflect.get(win, "__player"));
}
// ── Private ──────────────────────────────────────────────────────────────
private _injectRuntime(): void {
this._runtimeInjected = true;
try {
const doc = this._iframe.contentDocument;
if (!doc) return;
const script = doc.createElement("script");
script.src = RUNTIME_CDN_URL;
(doc.head || doc.documentElement).appendChild(script);
this._callbacks.onRuntimeInjected?.();
} catch {
/* cross-origin — can't inject */
}
}
private _resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null {
if (this.hasRuntimeBridge(win)) return null;
const timelines = Reflect.get(win, "__timelines");
if (!isObjectRecord(timelines)) return null;
const keys = Object.keys(timelines);
if (keys.length === 0) return null;
const rootId = this._iframe.contentDocument
?.querySelector("[data-composition-id]")
?.getAttribute("data-composition-id");
const key = rootId && rootId in timelines ? rootId : keys[keys.length - 1];
const timeline = timelines[key];
return isDirectTimelineAdapter(timeline) ? timeline : null;
}
private _resolvePlaybackDurationAdapter(win: Window): PlaybackDurationAdapter | null {
const runtimePlayer = Reflect.get(win, "__player");
if (isRuntimeDurationAdapter(runtimePlayer)) {
return { kind: "runtime", getDuration: () => runtimePlayer.getDuration() };
}
const timeline = this._resolveDirectTimelineAdapterFromWindow(win);
if (timeline) {
return {
kind: "direct-timeline",
timeline,
getDuration: () => timeline.duration(),
};
}
return null;
}
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Helpers for wiring the player's optional UI elements: the playback controls
* bar, the poster image overlay, and input-filtering utilities.
*
* Extracted from the web component so the setup logic doesn't inflate the
* class body. These are pure imperative DOM operations; they carry no
* persistent state beyond what the caller tracks.
*/
import { createControls, type ControlsCallbacks, type ControlsOptions } from "./controls.js";
/**
* Create the playback controls overlay and attach it to `parent`.
* Returns the controls API object. A no-op guard (returns the existing API)
* must be enforced by the caller — this function always constructs.
*/
export function setupControls(
parent: ShadowRoot,
muted: boolean,
volume: number,
speedPresetsAttr: string | null,
callbacks: ControlsCallbacks,
): ReturnType<typeof createControls> {
const speedPresets = speedPresetsAttr
? speedPresetsAttr
.split(",")
.map(Number)
.filter((n) => !isNaN(n) && n > 0)
: undefined;
const options: ControlsOptions = speedPresets ? { speedPresets } : {};
const api = createControls(parent, callbacks, options);
api.updateMuted(muted);
api.updateVolume(volume);
return api;
}
/**
* Set up or remove the poster image element in `parent`.
*
* - When `posterUrl` is non-empty, creates the `<img>` if needed and sets src.
* - When `posterUrl` is null/empty, removes the existing element (if any).
*
* Returns the current poster element (possibly newly created) or `null`.
*/
export function setupPoster(
parent: ShadowRoot,
posterUrl: string | null,
existing: HTMLImageElement | null,
): HTMLImageElement | null {
if (!posterUrl) {
existing?.remove();
return null;
}
if (!existing) {
existing = document.createElement("img");
existing.className = "hfp-poster";
parent.appendChild(existing);
}
existing.src = posterUrl;
return existing;
}
/**
* Returns `true` when `event` originated inside an `hfp-controls` element.
* Used to prevent the bare-player-surface click handler from double-firing
* when the user clicks an overlay control button.
*/
export function isControlsClick(event: Event): boolean {
return event
.composedPath()
.some((t) => t instanceof HTMLElement && t.classList.contains("hfp-controls"));
}
@@ -0,0 +1,96 @@
/**
* rAF-based clock that polls a `DirectTimelineAdapter` for current time and
* drives the player's time/playback-ended callbacks.
*
* Used for same-origin standalone GSAP compositions that expose
* `window.__timelines` but have no runtime bridge — the player drives them
* directly through the adapter instead of going through postMessage.
*/
import type { DirectTimelineAdapter } from "./timeline-adapters.js";
const UI_UPDATE_INTERVAL_MS = 100;
export interface ClockCallbacks {
/** Called every ~100ms and on completion with the current time. */
onTimeUpdate: (currentTime: number, duration: number) => void;
/** Called when playback reaches the end. Return true to loop (seek+play). */
onEnded: () => boolean;
/** Get the current loop flag. */
getLoop: () => boolean;
/** Trigger a seek-then-play loop restart. */
restart: () => void;
/** Notify that playback has paused (from the timeline side). */
onPaused: () => void;
}
export class DirectTimelineClock {
private _raf: number | null = null;
private _lastUpdateMs = 0;
constructor(private readonly _callbacks: ClockCallbacks) {}
start(
timeline: DirectTimelineAdapter,
getCurrentTime: () => number,
getDuration: () => number,
isPaused: () => boolean,
): void {
this.stop();
const tick = () => {
if (isPaused()) {
this._raf = null;
return;
}
let currentTime: number;
try {
currentTime = timeline.time();
} catch {
this._raf = null;
return;
}
const duration = getDuration();
if (duration > 0) currentTime = Math.min(currentTime, duration);
const completedPlayback = duration > 0 && currentTime >= duration;
const now = performance.now();
if (now - this._lastUpdateMs > UI_UPDATE_INTERVAL_MS || completedPlayback) {
this._lastUpdateMs = now;
this._callbacks.onTimeUpdate(currentTime, duration);
}
if (completedPlayback) {
if (this._callbacks.getLoop()) {
this._callbacks.restart();
return;
}
try {
timeline.pause();
} catch {
/* ignore */
}
this._callbacks.onPaused();
this._raf = null;
return;
}
this._raf = requestAnimationFrame(tick);
};
this._raf = requestAnimationFrame(tick);
}
stop(): void {
if (this._raf === null) return;
cancelAnimationFrame(this._raf);
this._raf = null;
}
get isRunning(): boolean {
return this._raf !== null;
}
}
@@ -548,8 +548,15 @@ describe("HyperframesPlayer media MutationObserver scoping", () => {
expect(observedTargets).not.toContain(fakeDoc.body);
// Subtree is still required — sub-composition media can be deeply nested
// inside the host (e.g. wrapper div around the `<audio>`).
// Attribute observation on "preload" is required so the player creates
// parent proxies just-in-time when the preloader promotes a clip.
for (const call of observeSpy.mock.calls) {
expect(call[1]).toEqual({ childList: true, subtree: true });
expect(call[1]).toEqual({
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["preload"],
});
}
});
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
/**
* DOM setup helpers for the player's shadow root.
*
* Keeps constructor boilerplate out of the web component class body.
*/
// Cached Constructable Stylesheet shared across all player instances.
let _sharedSheet: CSSStyleSheet | null = null;
/**
* Adopt `cssText` into `shadow` via a shared Constructable Stylesheet when the
* browser supports it, falling back to a `<style>` element injection. The sheet
* is cached on first creation and reused across all player instances.
*/
export function adoptShadowStyles(shadow: ShadowRoot, cssText: string): void {
if (typeof CSSStyleSheet !== "undefined") {
try {
if (!_sharedSheet) {
_sharedSheet = new CSSStyleSheet();
_sharedSheet.replaceSync(cssText);
}
shadow.adoptedStyleSheets = [_sharedSheet];
return;
} catch {
/* fallthrough */
}
}
const style = document.createElement("style");
style.textContent = cssText;
shadow.appendChild(style);
}
/**
* Creates and configures the iframe element that hosts the composition, plus
* the wrapper container div. Returns handles to both so the constructor can
* attach them to the shadow root and track references without inlining the
* boilerplate.
*/
export function createCompositionIframe(): {
container: HTMLDivElement;
iframe: HTMLIFrameElement;
} {
const container = document.createElement("div");
container.className = "hfp-container";
const iframe = document.createElement("iframe");
iframe.className = "hfp-iframe";
iframe.sandbox.add("allow-scripts", "allow-same-origin");
iframe.allow = "autoplay; fullscreen";
iframe.referrerPolicy = "no-referrer";
iframe.title = "HyperFrames Composition";
container.appendChild(iframe);
return { container, iframe };
}
/**
* Scale the iframe so the composition fits inside the player element while
* preserving aspect ratio. No-ops when the player has no painted size yet.
*/
export function scaleIframeToFit(
playerElement: HTMLElement,
iframe: HTMLIFrameElement,
compositionWidth: number,
compositionHeight: number,
): void {
const rect = playerElement.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const scale = Math.min(rect.width / compositionWidth, rect.height / compositionHeight);
iframe.style.width = `${compositionWidth}px`;
iframe.style.height = `${compositionHeight}px`;
iframe.style.transform = `translate(-50%, -50%) scale(${scale})`;
}
+359
View File
@@ -0,0 +1,359 @@
/**
* Parent-frame media proxy subsystem.
*
* Maintains mirror copies of the iframe's timed `<audio>`/`<video>` elements
* in the parent frame so that mobile browsers — which gate `el.play()` on user
* activation in the *same* frame — can still produce audible output via proxies
* the parent controls directly.
*
* See the class-level JSDoc on `HyperframesPlayer` for the full ownership model.
*/
import { selectMediaObserverTargets } from "./mediaObserverScope.js";
/** Minimum absolute drift before a currentTime correction is attempted. */
const MIRROR_DRIFT_THRESHOLD_SECONDS = 0.05;
/**
* How many *consecutive* over-threshold samples are required before issuing a
* `currentTime` write. Absorbs single-sample jitter (GC pause, slow bridge
* tick) without thrashing. Forced calls bypass this gate.
*
* Worst-case correction latency ≈ this × bridgeMaxPostIntervalMs (80 ms in
* core/runtime/state.ts) = 160 ms — well under human A/V re-sync tolerance.
*/
const MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES = 2;
export interface ProxyEntry {
el: HTMLMediaElement;
start: number;
duration: number;
/**
* Count of consecutive steady-state samples in which the proxy's
* `currentTime` was found drifted beyond `MIRROR_DRIFT_THRESHOLD_SECONDS`.
* Reset on every in-threshold sample. A write is only issued once this
* reaches `MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES`, absorbing
* single-sample jitter without thrashing.
*/
driftSamples: number;
}
export class ParentMediaManager {
private _entries: ProxyEntry[] = [];
private _mediaObserver?: MutationObserver;
private _playbackErrorPosted = false;
private _audioOwner: "runtime" | "parent" = "runtime";
private readonly _dispatchEvent: (event: Event) => void;
private readonly _getMuted: () => boolean;
private readonly _getVolume: () => number;
private readonly _getPlaybackRate: () => number;
private readonly _getCurrentTime: () => number;
private readonly _isPaused: () => boolean;
constructor(opts: {
dispatchEvent: (event: Event) => void;
getMuted: () => boolean;
getVolume: () => number;
getPlaybackRate: () => number;
getCurrentTime: () => number;
isPaused: () => boolean;
}) {
this._dispatchEvent = opts.dispatchEvent;
this._getMuted = opts.getMuted;
this._getVolume = opts.getVolume;
this._getPlaybackRate = opts.getPlaybackRate;
this._getCurrentTime = opts.getCurrentTime;
this._isPaused = opts.isPaused;
}
get audioOwner(): "runtime" | "parent" {
return this._audioOwner;
}
/** Exposed for test instrumentation only — do not use in production code. */
get entries(): ProxyEntry[] {
return this._entries;
}
get playbackErrorPosted(): boolean {
return this._playbackErrorPosted;
}
resetForIframeLoad(): void {
this._playbackErrorPosted = false;
const wasPromoted = this._audioOwner === "parent";
this._audioOwner = "runtime";
this.pauseAll();
this.teardownObserver();
if (wasPromoted) {
this._dispatchEvent(
new CustomEvent("audioownershipchange", {
detail: { owner: "runtime", reason: "iframe-reload" },
}),
);
}
}
destroy(): void {
this.teardownObserver();
for (const m of this._entries) {
m.el.pause();
m.el.src = "";
}
this._entries = [];
}
updateMuted(muted: boolean): void {
for (const m of this._entries) m.el.muted = muted;
}
updateVolume(volume: number): void {
for (const m of this._entries) m.el.volume = volume;
}
updatePlaybackRate(rate: number): void {
for (const m of this._entries) m.el.playbackRate = rate;
}
playAll(): void {
for (const m of this._entries) {
if (!m.el.src) continue;
m.el.play().catch((err: unknown) => this._reportPlaybackError(err));
}
}
pauseAll(): void {
for (const m of this._entries) m.el.pause();
}
seekAll(timeInSeconds: number): void {
for (const m of this._entries) {
const relTime = timeInSeconds - m.start;
if (relTime >= 0 && relTime < m.duration) m.el.currentTime = relTime;
}
}
/**
* Mirror parent-proxy `currentTime` to the iframe timeline, with optional
* jitter-coalescing. Pass `{ force: true }` for alignment moments (ownership
* promotion, new proxy initialization) where drift must be corrected
* immediately.
*/
mirrorTime(timelineSeconds: number, options?: { force?: boolean }): void {
const force = options?.force === true;
for (const m of this._entries) {
const relTime = timelineSeconds - m.start;
if (relTime < 0 || relTime >= m.duration) {
m.driftSamples = 0;
continue;
}
if (Math.abs(m.el.currentTime - relTime) > MIRROR_DRIFT_THRESHOLD_SECONDS) {
m.driftSamples += 1;
if (force || m.driftSamples >= MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES) {
m.el.currentTime = relTime;
m.driftSamples = 0;
}
} else {
m.driftSamples = 0;
}
}
}
/**
* Take ownership of audible playback in response to the runtime's
* `media-autoplay-blocked` signal. Idempotent.
*
* The caller is responsible for muting the iframe's own media output via the
* postMessage bridge (`set-media-output-muted`) after calling this.
*/
/**
* Take ownership of audible playback. Idempotent. The `onMirror` callback
* is called with the current timeline time and `{ force: true }` so the
* caller's mirror implementation runs (enabling test spies on the player
* to fire). If omitted, `mirrorTime` is called directly.
*/
promoteToParentProxy(
iframeDoc: Document | null,
onMirror?: (t: number, opts: { force: boolean }) => void,
): void {
if (this._audioOwner === "parent") return;
this._audioOwner = "parent";
// Synchronously mute iframe media to close the race window.
if (iframeDoc) {
for (const el of iframeDoc.querySelectorAll<HTMLMediaElement>("video, audio")) {
el.muted = true;
}
}
// One-shot alignment — bypass jitter-coalescing gate.
const t = this._getCurrentTime();
if (onMirror) onMirror(t, { force: true });
else this.mirrorTime(t, { force: true });
if (!this._isPaused()) this.playAll();
this._dispatchEvent(
new CustomEvent("audioownershipchange", {
detail: { owner: "parent", reason: "autoplay-blocked" },
}),
);
}
/**
* Set up proxies for all timed media currently in the iframe document, then
* install a MutationObserver for media added later (sub-composition activation).
*/
setupFromIframe(iframeDoc: Document): void {
const mediaEls = iframeDoc.querySelectorAll<HTMLMediaElement>(
"audio[data-start], video[data-start]",
);
for (const iframeEl of mediaEls) this._adoptIframeMedia(iframeEl);
this._observeDynamicMedia(iframeDoc);
}
/** Set up a single proxy from an explicit URL (the `audio-src` attribute path). */
setupFromUrl(audioSrc: string): void {
this._createEntry(audioSrc, "audio", 0, Infinity);
}
teardownObserver(): void {
this._mediaObserver?.disconnect();
this._mediaObserver = undefined;
}
// ── Private ──────────────────────────────────────────────────────────────
private _reportPlaybackError(err: unknown): void {
if (this._playbackErrorPosted) return;
this._playbackErrorPosted = true;
this._dispatchEvent(
new CustomEvent("playbackerror", { detail: { source: "parent-proxy", error: err } }),
);
}
/**
* Create a parent-frame media element and start preloading it. Returns the
* new entry, or `null` if a proxy for this src already exists (dedup).
*/
private _createEntry(
src: string,
tag: "audio" | "video",
start: number,
duration: number,
): ProxyEntry | null {
if (this._entries.some((m) => m.el.src === src)) return null;
const el = tag === "video" ? document.createElement("video") : new Audio();
el.preload = "auto";
el.src = src;
el.load();
el.muted = this._getMuted();
el.volume = this._getVolume();
const rate = this._getPlaybackRate();
if (rate !== 1) el.playbackRate = rate;
const entry: ProxyEntry = { el, start, duration, driftSamples: 0 };
this._entries.push(entry);
return entry;
}
private _adoptIframeMedia(iframeEl: HTMLMediaElement): void {
// Skip elements the preloader has demoted — the observer will re-trigger
// when the preload attribute is promoted to "auto".
if (iframeEl.preload === "metadata" || iframeEl.preload === "none") return;
const rawSrc =
iframeEl.getAttribute("src") || iframeEl.querySelector("source")?.getAttribute("src");
if (!rawSrc) return;
const src = new URL(rawSrc, iframeEl.ownerDocument.baseURI).href;
const start = parseFloat(iframeEl.getAttribute("data-start") || "0");
const duration = parseFloat(iframeEl.getAttribute("data-duration") || "Infinity");
const tag = iframeEl.tagName === "VIDEO" ? ("video" as const) : ("audio" as const);
const created = this._createEntry(src, tag, start, duration);
// If already under parent ownership and playing, the new proxy must catch
// up immediately — bypass the jitter-coalescing gate.
if (created && this._audioOwner === "parent") {
this.mirrorTime(this._getCurrentTime(), { force: true });
if (!this._isPaused() && created.el.src) {
created.el.play().catch((err: unknown) => this._reportPlaybackError(err));
}
}
}
private _detachIframeMedia(iframeEl: HTMLMediaElement): void {
const rawSrc =
iframeEl.getAttribute("src") || iframeEl.querySelector("source")?.getAttribute("src");
if (!rawSrc) return;
const src = new URL(rawSrc, iframeEl.ownerDocument.baseURI).href;
const idx = this._entries.findIndex((m) => m.el.src === src);
if (idx === -1) return;
const entry = this._entries[idx];
entry.el.pause();
entry.el.src = "";
this._entries.splice(idx, 1);
}
private _observeDynamicMedia(doc: Document): void {
this.teardownObserver();
if (typeof MutationObserver === "undefined" || !doc.body) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === "attributes" && m.attributeName === "preload") {
const target = m.target;
if (
target instanceof HTMLMediaElement &&
target.matches("audio[data-start], video[data-start]") &&
target.preload === "auto"
) {
this._adoptIframeMedia(target);
}
continue;
}
for (const added of m.addedNodes) {
if (!(added instanceof Element)) continue;
const candidates: HTMLMediaElement[] = [];
if (added.matches?.("audio[data-start], video[data-start]")) {
candidates.push(added as HTMLMediaElement);
}
const inside = added.querySelectorAll?.<HTMLMediaElement>(
"audio[data-start], video[data-start]",
);
if (inside) for (const el of inside) candidates.push(el);
for (const el of candidates) this._adoptIframeMedia(el);
}
for (const removed of m.removedNodes) {
if (!(removed instanceof Element)) continue;
const dropped: HTMLMediaElement[] = [];
if (removed.matches?.("audio[data-start], video[data-start]")) {
dropped.push(removed as HTMLMediaElement);
}
const inside = removed.querySelectorAll?.<HTMLMediaElement>(
"audio[data-start], video[data-start]",
);
if (inside) for (const el of inside) dropped.push(el);
for (const el of dropped) this._detachIframeMedia(el);
}
}
});
const observeOpts: MutationObserverInit = {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["preload"],
};
const targets = selectMediaObserverTargets(doc);
for (const target of targets) {
obs.observe(target, observeOpts);
}
this._mediaObserver = obs;
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Pure playback-state update logic for the `state` message from the runtime.
*
* Extracted from the web component so the state-transition rules — loop
* handling, play/pause mirroring, completion detection — can be read and
* exercised independently.
*/
import type { ParentMediaManager } from "./parent-media.js";
const UI_UPDATE_INTERVAL_MS = 100;
export interface PlaybackState {
currentTime: number;
duration: number;
paused: boolean;
lastUpdateMs: number;
}
export interface PlaybackStateCallbacks {
updateControlsTime: (current: number, duration: number) => void;
updateControlsPlaying: (playing: boolean) => void;
dispatchEvent: (event: Event) => void;
seek: (t: number) => void;
play: () => void;
getLoop: () => boolean;
media: ParentMediaManager;
}
/**
* Process a `state` message from the runtime and return the next state.
* Side effects (controls updates, events, media mirroring) are fired through
* `callbacks`. The caller must commit the returned state object.
*/
export function applyRuntimeStateMessage(
data: { frame: number; isPlaying: boolean },
fps: number,
current: PlaybackState,
callbacks: PlaybackStateCallbacks,
): PlaybackState {
const rawTime = (data.frame ?? 0) / fps;
const currentTime = current.duration > 0 ? Math.min(rawTime, current.duration) : rawTime;
const wasPlaying = !current.paused;
const nextPaused = !data.isPlaying;
const completedPlayback =
current.duration > 0 && currentTime >= current.duration && (wasPlaying || data.isPlaying);
if (completedPlayback && callbacks.getLoop()) {
if (callbacks.media.audioOwner === "parent") callbacks.media.pauseAll();
callbacks.seek(0);
callbacks.play();
// play() sets paused=false; reflect that in the returned state so the
// caller's destructure doesn't overwrite it with the stale nextPaused value.
return { ...current, currentTime, paused: false };
}
const next: PlaybackState = { ...current, currentTime, paused: nextPaused };
if (callbacks.media.audioOwner === "parent") {
if (wasPlaying && nextPaused) {
callbacks.media.pauseAll();
} else if (!wasPlaying && !nextPaused) {
callbacks.media.playAll();
}
callbacks.media.mirrorTime(currentTime);
}
const now = performance.now();
const playStateChanged = nextPaused !== current.paused;
if (now - current.lastUpdateMs > UI_UPDATE_INTERVAL_MS || playStateChanged) {
next.lastUpdateMs = now;
callbacks.updateControlsTime(currentTime, current.duration);
callbacks.updateControlsPlaying(!nextPaused);
callbacks.dispatchEvent(new CustomEvent("timeupdate", { detail: { currentTime } }));
}
if (completedPlayback) {
if (callbacks.media.audioOwner === "parent") callbacks.media.pauseAll();
next.paused = true;
callbacks.updateControlsPlaying(false);
callbacks.dispatchEvent(new Event("ended"));
}
return next;
}
@@ -0,0 +1,94 @@
/**
* Routes postMessages from the composition iframe to the appropriate handlers.
*
* Accepts the raw MessageEvent and delegates through typed callbacks so the
* web component keeps its state fields private and this module stays stateless.
*/
import {
applyRuntimeStateMessage,
type PlaybackState,
type PlaybackStateCallbacks,
} from "./playback-state.js";
import type { ShaderLoaderState } from "./shader-loader-state.js";
import type { ShaderTransitionState } from "./shader-options.js";
const FPS = 30;
export interface MessageHandlerCallbacks extends PlaybackStateCallbacks {
getPlaybackState: () => PlaybackState;
setPlaybackState: (next: PlaybackState) => void;
getShaderLoadingMode: () => string;
shaderLoader: ShaderLoaderState;
setCompositionSize: (width: number, height: number) => void;
sendControl: (action: string, extra?: Record<string, unknown>) => void;
getIframeDoc: () => Document | null;
}
export function handleRuntimeMessage(
event: MessageEvent,
frameWindow: Window | null,
callbacks: MessageHandlerCallbacks,
): void {
if (event.source !== frameWindow) return;
const data = event.data as Record<string, unknown> | undefined;
if (!data || data["source"] !== "hf-preview") return;
if (data["type"] === "shader-transition-state") {
const state: ShaderTransitionState =
data["state"] && typeof data["state"] === "object"
? (data["state"] as ShaderTransitionState)
: {};
callbacks.shaderLoader.update(state, callbacks.getShaderLoadingMode());
callbacks.dispatchEvent(
new CustomEvent("shadertransitionstate", {
detail: { compositionId: data["compositionId"], state },
}),
);
return;
}
if (data["type"] === "state") {
callbacks.setPlaybackState(
applyRuntimeStateMessage(
{ frame: (data["frame"] as number) ?? 0, isPlaying: !!data["isPlaying"] },
FPS,
callbacks.getPlaybackState(),
callbacks,
),
);
return;
}
if (data["type"] === "media-autoplay-blocked") {
let iframeDoc: Document | null = null;
try {
iframeDoc = callbacks.getIframeDoc();
} catch {
/* cross-origin */
}
callbacks.media.promoteToParentProxy(iframeDoc, (t, opts) =>
callbacks.media.mirrorTime(t, opts),
);
callbacks.sendControl("set-media-output-muted", { muted: true });
return;
}
if (data["type"] === "timeline" && (data["durationInFrames"] as number) > 0) {
if (Number.isFinite(data["durationInFrames"])) {
const pb = callbacks.getPlaybackState();
const duration = (data["durationInFrames"] as number) / FPS;
callbacks.setPlaybackState({ ...pb, duration });
callbacks.updateControlsTime(pb.currentTime, duration);
}
return;
}
if (
data["type"] === "stage-size" &&
(data["width"] as number) > 0 &&
(data["height"] as number) > 0
) {
callbacks.setCompositionSize(data["width"] as number, data["height"] as number);
}
}
@@ -0,0 +1,126 @@
/**
* Factory for the shader-transition loading overlay DOM tree.
*
* Kept in its own module so the ~100-line DOM construction stays out of the
* web component class body. The returned `ShaderLoaderElements` bag gives the
* component direct handles to the nodes it needs to update without querying
* the shadow DOM on every state change.
*/
import { SHADER_LOADING_PHRASES } from "./shader-options.js";
export interface ShaderLoaderElements {
root: HTMLDivElement;
fill: HTMLDivElement;
title: HTMLSpanElement;
detail: HTMLDivElement;
transitionValue: HTMLSpanElement;
frameLabel: HTMLSpanElement;
frameValue: HTMLSpanElement;
frameRow: HTMLDivElement;
}
export function createShaderLoader(): ShaderLoaderElements {
const root = document.createElement("div");
root.className = "hfp-shader-loader";
root.setAttribute("role", "status");
root.setAttribute("aria-live", "polite");
root.setAttribute("aria-label", "Preparing scene transitions");
root.setAttribute("data-hyperframes-ignore", "");
root.draggable = false;
const blockOverlayInteraction = (event: Event) => {
event.preventDefault();
event.stopPropagation();
};
for (const eventName of [
"selectstart",
"dragstart",
"pointerdown",
"mousedown",
"click",
"dblclick",
"contextmenu",
"touchstart",
]) {
root.addEventListener(eventName, blockOverlayInteraction, { capture: true });
}
const panel = document.createElement("div");
panel.className = "hfp-shader-loader-panel";
panel.draggable = false;
const markFrame = document.createElement("div");
markFrame.className = "hfp-shader-loader-mark";
markFrame.draggable = false;
markFrame.innerHTML = [
'<svg width="78" height="78" viewBox="0 0 100 100" fill="none" aria-hidden="true" draggable="false">',
'<path d="M10.1851 57.8021L33.1145 73.8313C36.2202 75.9978 41.5173 73.5433 42.4816 69.4984L51.7611 30.4271C52.7253 26.3822 48.5802 23.9277 44.4602 26.0942L13.917 42.1235C6.96677 45.7676 4.97564 54.1579 10.1851 57.8021Z" fill="url(#hfp-shader-loader-grad-left)"/>',
'<path d="M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z" fill="url(#hfp-shader-loader-grad-right)"/>',
"<defs>",
'<linearGradient id="hfp-shader-loader-grad-left" x1="48.5676" y1="25" x2="44.7804" y2="71.9384" gradientUnits="userSpaceOnUse">',
'<stop stop-color="#06E3FA"/>',
'<stop offset="1" stop-color="#4FDB5E"/>',
"</linearGradient>",
'<linearGradient id="hfp-shader-loader-grad-right" x1="54.8282" y1="73.8392" x2="72.0989" y2="32.8932" gradientUnits="userSpaceOnUse">',
'<stop stop-color="#06E3FA"/>',
'<stop offset="1" stop-color="#4FDB5E"/>',
"</linearGradient>",
"</defs>",
"</svg>",
].join("");
const titleContainer = document.createElement("div");
titleContainer.className = "hfp-shader-loader-title";
const titleText = document.createElement("span");
titleText.className = "hfp-shader-loader-title-text";
titleText.textContent = SHADER_LOADING_PHRASES[0] || "Preparing scene transitions";
titleContainer.appendChild(titleText);
const detail = document.createElement("div");
detail.className = "hfp-shader-loader-detail";
detail.textContent = "Rendering animated scene samples for shader transitions.";
const track = document.createElement("div");
track.className = "hfp-shader-loader-track";
track.setAttribute("aria-hidden", "true");
const fill = document.createElement("div");
fill.className = "hfp-shader-loader-fill";
track.appendChild(fill);
const progress = document.createElement("div");
progress.className = "hfp-shader-loader-progress";
const createProgressRow = (labelText: string) => {
const row = document.createElement("div");
row.className = "hfp-shader-loader-row";
const label = document.createElement("span");
label.className = "hfp-shader-loader-label";
label.textContent = labelText;
const value = document.createElement("span");
value.className = "hfp-shader-loader-value";
row.appendChild(label);
row.appendChild(value);
progress.appendChild(row);
return { row, label, value };
};
const transitionStatus = createProgressRow("transition");
const frameStatus = createProgressRow("transition frame");
panel.appendChild(markFrame);
panel.appendChild(titleContainer);
panel.appendChild(detail);
panel.appendChild(track);
panel.appendChild(progress);
root.appendChild(panel);
return {
root,
fill,
title: titleText,
detail,
transitionValue: transitionStatus.value,
frameLabel: frameStatus.label,
frameValue: frameStatus.value,
frameRow: frameStatus.row,
};
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Runtime state controller for the shader-transition loading overlay.
*
* Manages show/hide transitions (with a CSS fade-out delay) and updates
* the progress bar, phrase text, and detail rows from `ShaderTransitionState`
* messages received from the iframe.
*
* Holds direct references to the DOM nodes created by `createShaderLoader`
* so state updates never touch the shadow-DOM query API at runtime.
*/
import { SHADER_LOADING_PHRASES, type ShaderTransitionState } from "./shader-options.js";
import type { ShaderLoaderElements } from "./shader-loader-element.js";
const HIDE_TRANSITION_MS = 420;
export class ShaderLoaderState {
private readonly _el: ShaderLoaderElements;
private _hideTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(elements: ShaderLoaderElements) {
this._el = elements;
}
show(): void {
if (this._hideTimeout) {
clearTimeout(this._hideTimeout);
this._hideTimeout = null;
}
this._el.root.classList.remove("hfp-hiding");
this._el.root.classList.add("hfp-visible");
}
hide(): void {
if (this._el.root.classList.contains("hfp-hiding")) {
if (!this._hideTimeout) this._scheduleCleanup();
return;
}
if (!this._el.root.classList.contains("hfp-visible")) return;
this._el.root.classList.add("hfp-hiding");
this._el.root.classList.remove("hfp-visible");
this._scheduleCleanup();
}
reset(): void {
if (this._hideTimeout) {
clearTimeout(this._hideTimeout);
this._hideTimeout = null;
}
this._el.root.classList.remove("hfp-visible", "hfp-hiding");
this._el.fill.style.transform = "scaleX(0)";
this._el.transitionValue.textContent = "";
this._el.frameValue.textContent = "";
this._el.frameRow.style.visibility = "hidden";
}
update(status: ShaderTransitionState, loadingMode: string): void {
if (loadingMode !== "player") {
this.reset();
return;
}
if (status.ready || !status.loading) {
this.hide();
return;
}
const progress =
typeof status.progress === "number" && Number.isFinite(status.progress) ? status.progress : 0;
const total =
typeof status.total === "number" && Number.isFinite(status.total) ? status.total : 0;
const ratio = total > 0 ? Math.min(1, Math.max(0, progress / total)) : 0;
const phraseIndex = Math.min(
SHADER_LOADING_PHRASES.length - 1,
Math.floor(ratio * SHADER_LOADING_PHRASES.length),
);
this._el.title.textContent =
SHADER_LOADING_PHRASES[phraseIndex] || "Preparing scene transitions";
this._el.detail.textContent =
status.phase === "cached"
? "Loading cached transition frames before playback."
: status.phase === "finalizing"
? "Uploading transition textures for smooth playback."
: "Rendering animated scene samples for shader transitions.";
this._el.fill.style.transform = `scaleX(${ratio})`;
this._el.transitionValue.textContent =
status.currentTransition !== undefined && status.transitionTotal !== undefined
? `${status.currentTransition}/${status.transitionTotal}`
: total > 0
? `${progress}/${total}`
: "";
const frameValue =
status.transitionFrame !== undefined && status.transitionFrames !== undefined
? `${status.transitionFrame}/${status.transitionFrames}`
: "";
this._el.frameLabel.textContent =
status.phase === "cached"
? "cached transition frames"
: status.phase === "finalizing"
? "finalizing transition frames"
: "rendering transition frames";
this._el.frameValue.textContent = frameValue;
this._el.frameRow.style.visibility = frameValue ? "visible" : "hidden";
this._el.root.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
this.show();
}
get hideTimeout(): ReturnType<typeof setTimeout> | null {
return this._hideTimeout;
}
destroy(): void {
if (this._hideTimeout) {
clearTimeout(this._hideTimeout);
this._hideTimeout = null;
}
}
private _scheduleCleanup(): void {
if (this._hideTimeout) clearTimeout(this._hideTimeout);
this._hideTimeout = setTimeout(() => {
this._el.root.classList.remove("hfp-hiding");
this._hideTimeout = null;
}, HIDE_TRANSITION_MS);
}
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Shader transition option types, constants, and pure helper functions for
* injecting shader capture scale and loading mode parameters into composition
* URLs and srcdoc HTML.
*/
export const SHADER_CAPTURE_SCALE_ATTR = "shader-capture-scale";
export const SHADER_LOADING_ATTR = "shader-loading";
export const SHADER_CAPTURE_SCALE_PARAM = "__hf_shader_capture_scale";
export const SHADER_LOADING_PARAM = "__hf_shader_loading";
export const SHADER_LOADING_PHRASES = [
"Preparing scene transitions",
"Sampling outgoing scene motion",
"Sampling incoming scene motion",
"Caching transition frames",
"Finalizing transition preview",
];
export type ShaderLoadingMode = "composition" | "player" | "none";
export interface ShaderTransitionState {
ready?: boolean;
progress?: number;
total?: number;
currentTransition?: number;
transitionTotal?: number;
transitionFrame?: number;
transitionFrames?: number;
phase?: "cached" | "capturing" | "finalizing";
loading?: boolean;
}
export function normalizeShaderCaptureScale(value: string | null): string | null {
if (value === null) return null;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return String(Math.min(1, Math.max(0.25, parsed)));
}
export function normalizeShaderLoadingMode(value: string | null): ShaderLoadingMode {
if (value === null || value.trim() === "") return "composition";
const normalized = value.trim().toLowerCase();
if (
normalized === "none" ||
normalized === "false" ||
normalized === "0" ||
normalized === "off"
) {
return "none";
}
if (
normalized === "player" ||
normalized === "true" ||
normalized === "1" ||
normalized === "on"
) {
return "player";
}
return "composition";
}
function setQueryParam(params: URLSearchParams, key: string, value: string | null): void {
if (value === null) params.delete(key);
else params.set(key, value);
}
export function withShaderQueryParams(
src: string,
scale: string | null,
loadingMode: ShaderLoadingMode,
): string {
const hashIndex = src.indexOf("#");
const beforeHash = hashIndex >= 0 ? src.slice(0, hashIndex) : src;
const hash = hashIndex >= 0 ? src.slice(hashIndex) : "";
const queryIndex = beforeHash.indexOf("?");
const path = queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash;
const query = queryIndex >= 0 ? beforeHash.slice(queryIndex + 1) : "";
const params = new URLSearchParams(query);
setQueryParam(params, SHADER_CAPTURE_SCALE_PARAM, scale);
setQueryParam(params, SHADER_LOADING_PARAM, loadingMode === "composition" ? null : loadingMode);
const nextQuery = params.toString();
return `${path}${nextQuery ? `?${nextQuery}` : ""}${hash}`;
}
export function injectShaderOptionsIntoSrcdoc(
html: string,
scale: string | null,
loadingMode: ShaderLoadingMode,
): string {
if (scale === null && loadingMode === "composition") return html;
const lines: string[] = [];
if (scale !== null) lines.push(`window.__HF_SHADER_CAPTURE_SCALE=${JSON.stringify(scale)};`);
if (loadingMode !== "composition") {
lines.push(`window.__HF_SHADER_LOADING=${JSON.stringify(loadingMode)};`);
}
const script = `<script data-hyperframes-player-shader-options>${lines.join("")}</script>`;
if (/<head\b[^>]*>/i.test(html))
return html.replace(/<head\b[^>]*>/i, (match) => `${match}${script}`);
if (/<html\b[^>]*>/i.test(html))
return html.replace(/<html\b[^>]*>/i, (match) => `${match}${script}`);
return `${script}${html}`;
}
/**
* Convenience wrappers that read shader attributes directly from an element,
* avoiding boilerplate in the web component class body.
*/
export function getShaderModeFromElement(el: Element): ShaderLoadingMode {
return normalizeShaderLoadingMode(el.getAttribute(SHADER_LOADING_ATTR));
}
export function getShaderCaptureScaleFromElement(el: Element): number {
return Number(normalizeShaderCaptureScale(el.getAttribute(SHADER_CAPTURE_SCALE_ATTR)) ?? "1");
}
export function prepareSrcForElement(el: Element, src: string): string {
return withShaderQueryParams(
src,
normalizeShaderCaptureScale(el.getAttribute(SHADER_CAPTURE_SCALE_ATTR)),
getShaderModeFromElement(el),
);
}
export function prepareSrcdocForElement(el: Element, srcdoc: string): string {
return injectShaderOptionsIntoSrcdoc(
srcdoc,
normalizeShaderCaptureScale(el.getAttribute(SHADER_CAPTURE_SCALE_ATTR)),
getShaderModeFromElement(el),
);
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Types and type-guards for the two playback adapter paths the player supports:
*
* - `RuntimeDurationAdapter` — the HyperFrames runtime exposes `window.__player`
* with a `getDuration()` method. This is the standard path for compositions
* served through the runtime bridge.
*
* - `DirectTimelineAdapter` — same-origin standalone compositions can expose
* their GSAP master timeline at `window.__timelines` without installing the
* full runtime. The player drives play/pause/seek directly against the
* timeline object, bypassing the postMessage bridge.
*
* `PlaybackDurationAdapter` is the discriminated union the probe interval
* returns after deciding which path is available.
*/
export interface RuntimeDurationAdapter {
getDuration: () => number;
}
export interface DirectTimelineAdapter {
duration: () => number;
time: () => number;
seek: (timeInSeconds: number) => unknown;
play: () => unknown;
pause: () => unknown;
}
export type PlaybackDurationAdapter =
| { kind: "runtime"; getDuration: () => number }
| { kind: "direct-timeline"; timeline: DirectTimelineAdapter; getDuration: () => number };
export function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function isRuntimeDurationAdapter(value: unknown): value is RuntimeDurationAdapter {
return isObjectRecord(value) && typeof value.getDuration === "function";
}
export function isDirectTimelineAdapter(value: unknown): value is DirectTimelineAdapter {
return (
isObjectRecord(value) &&
typeof value.duration === "function" &&
typeof value.time === "function" &&
typeof value.seek === "function" &&
typeof value.play === "function" &&
typeof value.pause === "function"
);
}
BIN
View File
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env tsx
/**
* Render Benchmark
*
* Runs each test fixture multiple times and records per-stage timing
* plus peak heap/RSS memory. Results are saved to
* producer/tests/perf/benchmark-results.json.
*
* Usage:
* bun run benchmark # 3 runs per fixture (default)
* bun run benchmark -- --runs 5 # 5 runs per fixture
* bun run benchmark -- --only chat # single fixture
* bun run benchmark -- --exclude-tags slow
* bun run benchmark -- --tags hdr # only fixtures tagged "hdr"
* bun run bench:hdr # convenience: --tags hdr
*
* `--tags` and `--exclude-tags` may be passed together; a fixture must match
* at least one positive tag (when `--tags` is provided) AND must not match
* any excluded tag.
*/
export {};
//# sourceMappingURL=benchmark.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"benchmark.d.ts","sourceRoot":"","sources":["../src/benchmark.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;GAkBG"}

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