chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
# @hyperframes/core
Types, parsers, generators, compiler, linter, runtime, and frame adapters for the Hyperframes video framework.
## Install
```bash
npm install @hyperframes/core
```
> Most users don't need to install core directly — the [CLI](../cli), [producer](../producer), and [studio](../studio) packages depend on it internally.
## What's inside
| Module | Description |
| ------------------ | ---------------------------------------------------------------------------------------------------- |
| **Types** | `TimelineElement`, `CompositionSpec`, `Asset`, canvas dimensions, defaults |
| **Parsers** | `parseHtml` — extract timeline elements from HTML; `parseGsapScript` — parse GSAP animations |
| **Generators** | `generateHyperframesHtml` — produce valid Hyperframes HTML from a composition spec |
| **Compiler** | `compileTimingAttrs` — resolve `data-start` / `data-duration` into absolute times |
| **Linter** | `lintHyperframeHtml` — validate Hyperframes HTML (missing attributes, overlapping tracks, etc.) |
| **Runtime** | IIFE script injected into the browser — manages seek, media playback, and the `window.__hf` protocol |
| **Frame Adapters** | Pluggable animation drivers (GSAP, Lottie, CSS, or custom) |
## Frame Adapters
A frame adapter tells the engine how to seek your animation to a specific frame:
```typescript
import { createGSAPFrameAdapter } from "@hyperframes/core";
const adapter = createGSAPFrameAdapter({
getTimeline: () => gsap.timeline(),
compositionId: "my-video",
});
```
Implement `FrameAdapter` for custom animation runtimes:
```typescript
import type { FrameAdapter } from "@hyperframes/core";
const myAdapter: FrameAdapter = {
id: "my-adapter",
getDurationFrames: () => 300,
seekFrame: (frame) => {
/* seek your animation */
},
};
```
## Parsing and generating HTML
```typescript
import { parseHtml, generateHyperframesHtml } from "@hyperframes/core";
const { elements, metadata } = parseHtml(htmlString);
const html = generateHyperframesHtml(spec);
```
## Linting
```typescript
import { lintHyperframeHtml } from "@hyperframes/core/lint";
const result = lintHyperframeHtml(htmlString);
// result.findings: { severity, message, elementId }[]
```
## Documentation
Full documentation: [hyperframes.heygen.com/packages/core](https://hyperframes.heygen.com/packages/core)
## Related packages
- [`@hyperframes/engine`](../engine) — rendering engine that drives the browser
- [`@hyperframes/producer`](../producer) — full render pipeline (capture + encode)
- [`hyperframes`](../cli) — CLI
+73
View File
@@ -0,0 +1,73 @@
# Common Mistakes
Pitfalls that break HyperFrames compositions that can't be caught by the linter.
> **Linter:** Run `lintHyperframeHtml()` on your composition to catch most issues automatically. The linter detects: missing timeline registration, unmuted video, nested video in timed elements, missing `class="clip"`, deprecated attribute names (`data-layer`, `data-end`), and missing dimensions. See `core/src/lint/` for the full rule list.
---
## 1. Animating video element dimensions with GSAP
**Symptom:** Video frames stop updating, or browser performance drops.
**Cause:** GSAP animating `width`, `height`, `top`, `left` directly on a `<video>` element can cause the browser to stop rendering frames.
```js
// BROKEN — animating video element dimensions
tl.to("#el-video", { width: 500, height: 280, top: 700, left: 1400 }, 26);
// FIXED — animate a wrapper div, video fills it at 100%
tl.to("#pip-wrapper", { width: 500, height: 280, top: 700, left: 1400 }, 26);
```
Use a non-timed wrapper div for visual effects like picture-in-picture. Animate the wrapper; let the video fill it.
---
## 2. Controlling media playback in scripts
**Symptom:** Audio/video playback is out of sync, or plays when it shouldn't.
**Cause:** Calling `video.play()`, `video.pause()`, `audio.currentTime = ...` in your scripts. The framework owns all media playback.
```js
// BROKEN — conflicts with framework media sync
document.getElementById("el-video").play();
document.getElementById("el-audio").currentTime = 5;
// FIXED — don't do this. The framework handles it.
// Use GSAP for visual animations only:
tl.to("#el-video", { opacity: 1, duration: 0.5 }, 0);
```
---
## 3. Composition duration shorter than video
**Symptom:** Video plays for a few seconds then stops. Timeline shows 8-10 seconds even though the video is minutes long.
**Cause:** The composition duration equals the GSAP timeline duration, not `data-duration` on the video. If your last GSAP animation ends at 8 seconds, the composition is 8 seconds long.
```js
// BROKEN — timeline is only 8s long, video cuts off
tl.to("#lower-third", { left: -640, duration: 0.6 }, 7.2);
// Last tween ends at 7.8s → composition = 7.8s
// FIXED — extend timeline to match video length
tl.to("#lower-third", { left: -640, duration: 0.6 }, 7.2);
tl.set({}, {}, 283); // ← extends timeline to 283 seconds
```
`tl.set({}, {}, TIME)` adds a zero-duration tween at the specified time, which extends the timeline without affecting any elements.
---
## Debugging checklist
When something doesn't work, check in this order:
1. Run the linter: `lintHyperframeHtml(html)` — catches most structural issues
2. Is `window.__timelines["<id>"]` registered? Does the key match `data-composition-id`?
3. Are GSAP animations only on visual properties (opacity, transform, color)?
4. Is the GSAP timeline long enough? (`tl.set({}, {}, DURATION)` at the end)
5. Open browser console — runtime errors show up as `[Browser:ERROR]` or `[Browser:PAGEERROR]`
+538
View File
@@ -0,0 +1,538 @@
# HyperFrames Schema
Reference for generating and editing HyperFrames HTML compositions. This is your source of truth for how to author compositions.
**New to HyperFrames?** Start with the [quickstart template](./quickstart-template.html) — a copy-paste composition with inline comments explaining every required piece. See [common mistakes](./common-mistakes.md) for pitfalls that break compositions.
For Frame adapters and deterministic frame rendering direction, see [`../../FRAME.md`](../../FRAME.md) and [`../adapters/README.md`](../adapters/README.md).
Producer-canonical parity note:
- Producer render behavior is the source of truth for deterministic parity.
- Preview should emulate producer seek semantics (`renderSeek`, frame quantization, readiness gates) in parity mode.
- Non-parity smooth playback can exist, but parity mode is the correctness baseline.
## Overview
HyperFrames uses HTML as the source of truth for describing a video:
- **HTML clips** = video, image, audio, composition
- **Data attributes** = timing, metadata, styling
- **CSS** = positioning and appearance
- **GSAP timeline** = animations and playback sync
### Framework-Managed Behavior
The framework reads data attributes and automatically manages:
- **Primitive clip timeline entries** — the framework reads `data-start`, `data-duration`, and `data-track-index` from primitive clips and adds them to the composition's GSAP timeline. You do not manually add primitive clips to the timeline in scripts.
- **Media playback** (play, pause, seek) for `<video>` and `<audio>`
- **Clip lifecycle** — clips are **mounted** (made visible on screen) and **unmounted** (removed from screen) based on `data-start` and `data-duration`
- **Timeline synchronization** (keeping media in sync with the GSAP master timeline)
- **Media loading** — the framework waits for all media elements to load before resolving timing and starting playback
Mounting and unmounting controls **presence**, not appearance. A clip that is mounted is on screen; a clip that is unmounted is not. Transitions (fade in, slide in, etc.) are separate — they are animated in scripts and happen _after_ a clip is mounted or _before_ it is unmounted.
The framework does **not** handle transitions, effects, or visual animation — those are driven by GSAP in JavaScript.
Do not manually call `video.play()`, `video.pause()`, set `audio.currentTime`, or mount/unmount clips in scripts. The framework owns media playback and clip lifecycle. Animating visual properties like `opacity` or `transform` for transitions is fine — that's what scripts are for.
## Viewport
Every composition must include `data-width` and `data-height` so scripts and CSS can reference concrete pixel dimensions for layout. Common sizes:
- **Landscape**: `data-width="1920" data-height="1080"`
- **Portrait**: `data-width="1080" data-height="1920"`
e.g.,
```html
<div id="main" data-composition-id="my-video" data-start="0" data-width="1920" data-height="1080">
<!-- clips -->
</div>
```
Every composition's container is full-screen within the viewport by default. The framework applies full-screen sizing to composition containers automatically.
To position or size individual clips (e.g., picture-in-picture, overlay placement), use standard CSS on the element.
## Compositions
A composition is the fundamental grouping unit in HyperFrames. Every clip — video, image, audio — must live inside a composition. The `index.html` file is itself a composition (the top-level one), and it can contain nested compositions within it. Any composition can be imported into another composition as a sub-composition — there is no special "root" type.
A composition carries the same core attributes as any other clip (`id`, `data-start`, `data-track-index`), so it can be placed and timed on a timeline just like a video or image. A composition's length is determined by its GSAP timeline — there is no `data-duration` on compositions. This means compositions can be nested: a composition clip inside another composition behaves like a self-contained video within the parent timeline.
### Composition File Structure
**Each composition should be defined in its own HTML file.** This keeps compositions modular, reusable, and maintainable. The file contains the complete composition: HTML structure, inline styles, and script.
```
project/
├── index.html # Root composition
├── compositions/
│ ├── intro-anim.html # Intro animation composition
│ ├── caption-overlay.html # Caption composition
│ └── outro-title.html # Outro composition
```
**Composition file format** (`compositions/intro-anim.html`):
```html
<!-- Define the composition as a template that can be loaded -->
<template id="intro-anim-template">
<div data-composition-id="intro-anim" data-width="1920" data-height="1080">
<div class="title">Welcome!</div>
<div class="subtitle">Let's get started</div>
<style>
[data-composition-id="intro-anim"] .title {
font-size: 72px;
color: white;
text-align: center;
}
[data-composition-id="intro-anim"] .subtitle {
font-size: 36px;
color: #ccc;
text-align: center;
}
</style>
<script>
const tl = gsap.timeline({ paused: true });
tl.from(".title", { opacity: 0, y: -50, duration: 1 });
tl.from(".subtitle", { opacity: 0, y: 50, duration: 1 }, 0.5);
window.__timelines["intro-anim"] = tl;
</script>
</div>
</template>
```
### Loading Compositions
Use the `data-composition-src` attribute to load a composition from an external HTML file. The framework will automatically fetch the template and instantiate it:
```html
<div id="comp-1" data-composition-id="my-video" data-start="0" data-width="1920" data-height="1080">
<!-- Primitive clips -->
<video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="el-2" data-start="el-1" data-duration="8" data-track-index="0" src="..."></video>
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." />
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..." />
<!-- Load composition from external file -->
<div
id="el-5"
data-composition-id="intro-anim"
data-composition-src="compositions/intro-anim.html"
data-start="0"
data-track-index="3"
></div>
<!-- Another loaded composition -->
<div
id="el-6"
data-composition-id="captions"
data-composition-src="compositions/caption-overlay.html"
data-start="0"
data-track-index="4"
></div>
</div>
```
The framework will:
1. Fetch the HTML file specified in `data-composition-src`
2. Extract the `<template>` content
3. Clone and mount it into the composition element
4. Execute any `<script>` tags within the template
5. Register the timeline in `window.__timelines`
### Best Practices for Composition Files
**Use separate HTML files when:**
- The composition is reusable across multiple projects or scenes
- The composition has complex logic, styling, or structure (>20 lines)
- You want to keep the main `index.html` clean and focused on orchestration
- The composition represents a distinct functional unit (captions, titles, animations)
**Use inline compositions when:**
- The composition is truly one-off and project-specific
- The composition is very simple (<10 lines total)
- You're prototyping and iterating quickly
**File naming conventions:**
- Use kebab-case: `intro-anim.html`, `caption-overlay.html`, `emoji-burst.html`
- Name files descriptively based on their purpose or visual function
- Group related compositions in subdirectories if you have many: `compositions/titles/`, `compositions/overlays/`
## Clip Types
A clip is any discrete block on the timeline. We represent clips as HTML elements and apply data-attributes to describe them.
- `<video>` — Video clips, B-roll, A-roll
- `<img>` — Static images, overlays
- `<audio>` — Music, sound effects
- `<div data-composition-id="...">` — Nested compositions (animations, grouped sequences)
## HTML Attributes
### All Clips
- `id` — Unique identifier (e.g., "el-1")
- `data-start` — Start time in seconds, or a clip `id` reference. See [Relative Timing](#relative-timing).
- `data-duration` — Duration in seconds. Required for `<img>` clips. Optional for `<video>` and `<audio>` (defaults to the source media's full duration). Not used on compositions.
- `data-track-index` — Timeline track number. Tracks serve two purposes: they determine visual layering (higher tracks render in front) and they group clips into rows on the timeline. Clips on the same track **cannot overlap in time**.
### Media Clips (video, audio)
- `data-media-start` — (optional) Playback begins at this time in the source file, in seconds. Defaults to `0`.
### Composition Clips
- `data-composition-id` — Unique composition ID
- `data-composition-src` — (optional) Path to external HTML file containing the composition template. The framework will fetch, instantiate, and mount the template automatically.
> Compositions do **not** use `data-duration`. Their duration is determined by their GSAP timeline.
## Relative Timing
Instead of calculating absolute start times, a clip can reference another clip's `id` in its `data-start` attribute. This means "start when that clip ends." The referenced clip must be in the same composition and must have a known duration (either an explicit `data-duration` or an inferred duration from the source media).
### Basic Sequential Clips
```html
<video id="intro" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="main" data-start="intro" data-duration="20" data-track-index="0" src="..."></video>
<video id="outro" data-start="main" data-duration="5" data-track-index="0" src="..."></video>
```
`main` resolves to second 10, `outro` resolves to second 30. If `intro`'s duration changes to 15, `main` and `outro` shift automatically.
### Offsets (gaps and overlaps)
Add `+ N` or `- N` after the ID to offset from the end of the referenced clip:
```html
<!-- intro ends at 10. "intro + 2" = 10 + 2 = starts at second 12 (2s gap) -->
<video
id="scene-a"
data-start="intro + 2"
data-duration="20"
data-track-index="0"
src="..."
></video>
<!-- intro ends at 10. "intro - 0.5" = 10 - 0.5 = starts at second 9.5 (0.5s overlap for crossfade) -->
<!-- Different track because clips on the same track cannot overlap -->
<video
id="scene-b"
data-start="intro - 0.5"
data-duration="20"
data-track-index="1"
src="..."
></video>
```
### Rules
- **Same composition only** — references resolve within the clip's parent composition
- **No circular references** — A cannot start after B if B starts after A
- **Referenced clip must have a known duration** — the system needs a known end time to resolve the reference (either explicit `data-duration` or inferred from source media)
- **Parsing** — if the value is a valid number, it is absolute seconds; otherwise it is parsed as `<id>`, `<id> + <number>`, or `<id> - <number>`
## Video Clips
Full-screen or positioned video clips. Videos sync their playback to the timeline position.
```html
<video
id="el-1"
data-start="0"
data-duration="15"
data-track-index="0"
src="./assets/video.mp4"
></video>
```
- `data-media-start` — Playback begins at this time in the source video file (seconds). Default: `0`.
- `data-duration` — (optional) How long the clip occupies on the timeline, in seconds. Playback runs from `data-media-start` for up to `data-duration` seconds. If the source media runs out before `data-duration` elapses, playback naturally stops (the clip remains mounted showing the last frame). If omitted, defaults to the remaining duration of the source file from `data-media-start`.
## Image Clips
Static images that appear for a duration.
```html
<img id="el-2" data-start="5" data-duration="4" data-track-index="1" src="./assets/video.mp4" />
```
## Audio Clips
Background music or sound effects. Audio clips are invisible.
```html
<audio
id="el-4"
data-start="0"
data-duration="30"
data-track-index="2"
src="./assets/music.mp3"
></audio>
```
- `data-media-start` — Playback begins at this time in the source audio file (seconds). Default: `0`.
- `data-duration` — (optional) How long the clip occupies on the timeline, in seconds. Playback runs from `data-media-start` for up to `data-duration` seconds. If the source media runs out before `data-duration` elapses, playback naturally stops (the clip remains mounted but silent). If omitted, defaults to the remaining duration of the source file from `data-media-start`.
## Two Layers: Primitives and Scripts
Every composition — master or sub — has the same two layers:
- **HTML** — primitive clips (`video`, `img`, `audio`, nested `div[data-composition-id]`). This is the declarative structure: what plays, when, and on which track.
- **Script** — effects, transitions, dynamic DOM, canvas, SVG — creative animation and visuals via GSAP. Scripts do **not** control media playback or clip visibility; the framework handles those via data attributes.
Both layers are available to every composition. The schema defines the primitives and the timeline contract; scripts handle visual creativity on top of that.
> **Warning:** Never use scripts to play/pause/seek media elements or to show/hide clips based on timing. The framework does this automatically from `data-start`, `data-duration`, and `data-media-start`. Scripts that duplicate this behavior will conflict with the framework.
### Script Isolation
Each composition's script is scoped to that composition. When a composition is loaded from an external HTML file via `data-composition-src`, its inline `<script>` and `<style>` tags are automatically included and scoped to that composition.
**Preferred approach** — Composition in separate HTML file:
```html
<!-- In index.html -->
<div
id="el-5"
data-composition-id="intro-anim"
data-composition-src="compositions/intro-anim.html"
data-start="0"
data-track-index="2"
></div>
```
**Alternative approach** — External JS file:
```html
<!-- In index.html -->
<div
id="el-5"
data-composition-id="intro-anim"
data-start="0"
data-track-index="2"
data-width="1920"
data-height="1080"
>
<script src="intro-anim.js"></script>
</div>
```
The separate HTML file approach is preferred because it keeps all composition code (structure, style, script) in one self-contained, reusable file.
The only required file is `index.html`. Every composition must have at least a script to create and register its GSAP timeline.
### Top-Level Composition
The top-level composition is the `index.html` entry point. It acts as the conductor — sequencing clips and placing sub-composition timelines into an overall master timeline. It can technically do anything in its script, but its primary purpose is high-level orchestration. Any composition can serve as a top-level composition or be nested into another — there is no structural difference.
```html
<div id="comp-1" data-composition-id="my-video" data-start="0" data-width="1920" data-height="1080">
<!-- Primitive clips -->
<video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="el-2" data-start="el-1" data-duration="8" data-track-index="0" src="..."></video>
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." />
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..." />
<!-- Load sub-compositions from external files -->
<div
id="el-5"
data-composition-id="intro-anim"
data-composition-src="compositions/intro-anim.html"
data-start="0"
data-track-index="3"
></div>
<div
id="el-6"
data-composition-id="captions"
data-composition-src="compositions/caption-overlay.html"
data-start="0"
data-track-index="4"
></div>
<script>
// Just register the timeline - framework auto-nests sub-compositions
const tl = gsap.timeline({ paused: true });
window.__timelines["my-video"] = tl;
</script>
</div>
```
### Sub-Compositions
Sub-compositions are a spectrum. One might simply group a few primitive clips. Another might be a fully custom program with its own HTML, CSS, and JavaScript — creating, animating, and destroying DOM however it sees fit. There are no categories or constraints on what a sub-composition does internally. The only rule: it must be driven by a GSAP timeline and export it. If children use `position: absolute`, always set explicit `left`/`top`/`bottom`/`right` — the composition root is a positioned container, so omitting coordinates produces unpredictable placement.
## Wrapping Dynamic Content in Compositions
**Critical Rule: All visual content must live inside a composition with data attributes to appear in the timeline.**
When you have dynamic or script-animated content (captions, emojis, overlays, text animations), wrap them in a composition element with `data-start`, `data-duration` (or let the timeline determine duration), and `data-track-index`. The children inside can be freely created and animated via JavaScript—they don't need individual data attributes.
### Wrong: Dynamic content outside a composition
```html
<!-- BAD: captions-container is not a composition - won't appear in timeline -->
<div id="ui-layer">
<div id="captions-container">
<!-- Dynamically created caption groups via JS -->
</div>
<div class="emoji" id="emoji-1">🤩</div>
</div>
<script>
// These animations work visually but elements don't appear in timeline
tl.to(".caption-group", { opacity: 1 }, 0.5);
tl.to("#emoji-1", { scale: 1.2 }, 2);
</script>
```
### Correct: Dynamic content wrapped in compositions
**Preferred approach** — Load from external HTML files:
```html
<!-- GOOD: Each logical group is a composition loaded from its own file -->
<div
id="captions-comp"
data-composition-id="captions"
data-composition-src="compositions/captions.html"
data-start="0"
data-track-index="5"
></div>
<div
id="emojis-comp"
data-composition-id="emojis"
data-composition-src="compositions/emojis.html"
data-start="0"
data-track-index="6"
></div>
```
**Alternative approach** — Inline composition (useful for one-off custom compositions):
```html
<div
id="captions-comp"
data-composition-id="captions"
data-start="0"
data-track-index="5"
data-width="1080"
data-height="1920"
>
<!-- Children created/animated by script - no data attributes needed -->
<div id="captions-container"></div>
<script>
const captionTL = gsap.timeline({ paused: true });
// Dynamically create and animate caption groups...
window.__timelines["captions"] = captionTL;
</script>
</div>
<div
id="emojis-comp"
data-composition-id="emojis"
data-start="0"
data-track-index="6"
data-width="1080"
data-height="1920"
>
<div class="emoji" id="emoji-1">🤩</div>
<div class="emoji" id="emoji-2">🏔️</div>
<script>
const emojiTL = gsap.timeline({ paused: true });
emojiTL.to("#emoji-1", { opacity: 1, scale: 1.2 }, 2);
emojiTL.to("#emoji-2", { opacity: 1, scale: 1.2 }, 4);
window.__timelines["emojis"] = emojiTL;
</script>
</div>
```
### When to create separate compositions
- **Captions**: One composition for all captions, script manages word groups
- **Emojis/Stickers**: One composition for the emoji layer
- **Hooks/Titles**: One composition per distinct title sequence
- **Overlays**: Group related overlays into compositions by purpose
The composition appears in the timeline with its start time and duration (determined by its GSAP timeline). Everything inside is managed by the script but inherits the composition's timeline position.
### Caption discoverability contract
To make caption previews deterministic and easy to isolate in downstream UIs (timeline, mini-player, editor overlays), caption compositions should expose a stable root selector.
Use these attributes on the caption root node:
- `data-timeline-role="captions"` (required)
- `data-caption-root="true"` (recommended)
Example:
```html
<div
id="captions-comp"
data-composition-id="captions"
data-start="0"
data-track-index="5"
data-width="1080"
data-height="1920"
data-timeline-role="captions"
data-caption-root="true"
>
<div id="caption-container"></div>
<script>
const captionTL = gsap.timeline({ paused: true });
window.__timelines["captions"] = captionTL;
</script>
</div>
```
## Timeline Contract
The framework initializes `window.__timelines = {}` before any scripts run. Every composition **must** have a script that creates a GSAP timeline and registers it:
```js
const tl = gsap.timeline({ paused: true });
// ... add tweens, nested timelines, etc.
window.__timelines["<data-composition-id>"] = tl;
```
### Rules
- **Every composition needs a script** — at minimum, to create and register its timeline. A composition without a script has no timeline and cannot participate in the hierarchy.
- **All timelines start paused** — create timelines with `{ paused: true }`. The top-level timeline is controlled externally by the frontend player or renderer.
- **Framework auto-nests sub-timelines** — you do **not** need to manually add sub-composition timelines to the master timeline. The framework automatically nests any timeline registered in `window.__timelines` into its parent based on the composition's `data-start` attribute.
- **Duration comes from the timeline** — a composition's duration is `tl.duration()`. The timeline is the sole source of truth; there is no `data-duration` on compositions.
- **Timelines must be finite** — every timeline must have a finite duration. Infinite or indefinite timelines are not supported.
### What NOT to do
```js
// UNNECESSARY - the framework does this automatically
if (window.__timelines["captions"]) {
masterTL.add(window.__timelines["captions"], 0);
}
```
Just register your timeline in `window.__timelines` and the framework handles the rest.
## Output Checklist
- [ ] Every composition has `data-width` and `data-height` attributes
- [ ] Each reusable composition is in its own HTML file (in `compositions/` directory)
- [ ] Compositions loaded via `data-composition-src` attribute
- [ ] Each composition file uses `<template>` tag to wrap its content
- [ ] `window.__timelines` given all compositions' timelines
- [ ] Complex/dynamic animations are handled by scripts in compositions
+61
View File
@@ -0,0 +1,61 @@
# Core Notes
Extended design rationale and context for decisions in `core.md`. This file is for humans reviewing the design — it is not consumed by the LLM agent.
## Interactive Compositions
### Why `data-start="interactive"`
We considered three alternatives:
1. **`data-interactive` boolean attribute** — Keeps `data-start` clean, but then `data-start` must either be omitted (breaking the convention that every clip has `data-start`) or present but meaningless. Two attributes to express what one value handles.
2. **Event-driven `data-start="on:click:#element"`** — More expressive and extensible to other events (`on:hover`, `on:end:#video`), but complex to parse, harder for an LLM to author correctly, and the trigger/target relationship is declared on the target which reads backwards.
3. **`data-start="interactive"` (chosen)** — Reuses the existing `data-start` attribute with a new keyword value. Reads naturally: "when does this start?" → "interactively." One attribute, no ambiguity, easy to parse (`=== "interactive"` check).
The tradeoff is that `data-start` is now overloaded (was purely numeric/reference, now has a keyword). We accepted this because the parsing is trivial and the readability gain is significant.
### Why `window.__navigate()` instead of `data-goto`
Navigation is behavior, not structure. The framework's philosophy separates these: HTML declares structure and timing, scripts handle behavior.
A `data-goto="composition-id"` attribute on trigger elements would be declarative and concise, but limits what authors can do. With a runtime API, scripts can:
- Add conditional logic (`if (score > 50) navigate('win') else navigate('lose')`)
- Animate a transition before navigating
- Add delays or timeouts
- Chain multiple actions on a single click
- Use any DOM event, not just clicks
The trigger element is just normal HTML with a normal `addEventListener`. The LLM only needs to know `window.__navigate(id)` — plain JS.
### Navigation Behavior: Replace
When `__navigate()` is called:
1. The currently active composition's timeline pauses
2. The current composition hides (visibility/display)
3. The target interactive composition shows
4. The target's timeline seeks to 0 and plays
We chose Replace (parent hides entirely) over Overlay (target plays on top) or Pause-and-branch (parent pauses, resumes when target ends) because it's the simplest mental model and matches how most interactive video works (YouTube branching, Netflix Bandersnatch).
### Ownership Model
Interactive compositions are children of the composition that branches to them in the DOM tree. The parent composition is the "root" of its branching experience — it owns its branches.
However, `window.__navigate()` resolves composition IDs globally across the full tree, not scoped to the current parent. This means:
- A deeply nested interactive composition can navigate to any other interactive composition by ID
- A shared "game over" or "credits" composition can be reached from anywhere
- Circular navigation is possible (A → B → A) — the framework does not prevent loops
### Future Extensions
These are not implemented but the design accommodates them without breaking changes:
- **Overlay mode**: `window.__navigate(id, { mode: 'overlay' })` — target plays on top, parent pauses or continues
- **History / back**: `window.__navigate('$back')` to return to the previous composition, `window.__navigateHistory` to read the stack
- **Auto-advance / timeout**: Scripts can implement this today with `setTimeout(() => window.__navigate('default'), 10000)`. A declarative shorthand could be added later.
- **Pause-and-branch**: `window.__navigate(id, { mode: 'branch' })` — parent pauses, resumes when target ends
+189
View File
@@ -0,0 +1,189 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!--
REQUIRED: viewport must match your composition dimensions.
Landscape: width=1920, height=1080
Portrait: width=1080, height=1920
-->
<meta name="viewport" content="width=1920, height=1080" />
<!--
REQUIRED: data-composition-id identifies this composition.
Must match the key you register in window.__timelines below.
Optional: data-width/data-height help the producer determine resolution.
-->
<meta data-composition-id="my-video" data-width="1920" data-height="1080" />
<title>My Video</title>
<!-- REQUIRED: GSAP for timeline animations -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
overflow: hidden;
background: #000;
}
#stage {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
/*
REQUIRED: All timed clips need visibility: hidden.
The framework toggles visibility based on data-start/data-duration.
Never toggle visibility yourself in scripts.
*/
.clip {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
visibility: hidden;
object-fit: cover;
}
</style>
</head>
<body>
<div id="stage">
<!--
════════════════════════════════════════════════════
VIDEO CLIPS
════════════════════════════════════════════════════
REQUIRED attributes:
data-start — when the clip appears (seconds)
data-duration — how long the clip stays on screen (seconds)
(optional for video/audio — defaults to source duration)
data-track-index — z-stacking order + timeline track grouping
(higher = in front, clips on same track cannot overlap)
REQUIRED on <video>:
muted — framework manages audio via separate <audio> element
playsinline — prevents fullscreen on mobile
REQUIRED:
class="clip" — sets visibility: hidden so framework can manage lifecycle
DO NOT:
- Call video.play(), video.pause(), or set video.currentTime in scripts
- Toggle visibility in scripts
- Use the video element for audio (use a separate <audio> element)
-->
<video
id="el-video"
class="clip"
data-start="0"
data-duration="10"
data-track-index="0"
src="my-video.mp4"
muted
playsinline
></video>
<!--
════════════════════════════════════════════════════
IMAGE CLIPS
════════════════════════════════════════════════════
data-duration is REQUIRED for images (they have no intrinsic duration).
-->
<img
id="el-image"
class="clip"
data-start="10"
data-duration="5"
data-track-index="0"
src="my-image.png"
/>
<!--
════════════════════════════════════════════════════
AUDIO
════════════════════════════════════════════════════
Audio is invisible — no class="clip" needed (no visual to hide).
Use a separate <audio> element even if the source is the same .mp4 file.
The framework syncs audio playback to the timeline position.
Optional: data-volume="0.5" (0-1, default 1)
-->
<audio
id="el-audio"
data-start="0"
data-duration="15"
data-track-index="1"
data-volume="0.8"
src="my-video.mp4"
></audio>
<!--
════════════════════════════════════════════════════
TEXT / HTML OVERLAYS
════════════════════════════════════════════════════
Any div with data-start becomes a timed clip.
Content inside can be freely styled with CSS.
Animate visual properties (opacity, transform) with GSAP — fine.
Do NOT animate visibility — the framework owns that.
-->
<div
id="el-title"
class="clip"
data-start="1"
data-duration="4"
data-track-index="2"
style="display: flex; align-items: center; justify-content: center; z-index: 10"
>
<h1 style="font-family: sans-serif; font-size: 72px; color: white; opacity: 0">
Hello World
</h1>
</div>
</div>
<script>
/*
════════════════════════════════════════════════════
GSAP TIMELINE
════════════════════════════════════════════════════
REQUIRED: Create a paused GSAP timeline.
REQUIRED: Register it in window.__timelines with the SAME ID
as data-composition-id on the <meta> tag.
Without this registration, NOTHING works — no playback,
no seeking, no rendering. This is the #1 mistake.
*/
var tl = gsap.timeline({ paused: true });
// Animate visual properties only — opacity, transform, color, etc.
// The framework handles clip mounting/unmounting and media playback.
tl.to("#el-video", { opacity: 1, duration: 0.5 }, 0);
tl.to("#el-video", { opacity: 0, duration: 0.5 }, 9.5);
tl.to("#el-image", { opacity: 1, duration: 0.5 }, 10);
tl.to("#el-image", { opacity: 0, duration: 0.5 }, 14.5);
tl.to("#el-title h1", { opacity: 1, y: 0, duration: 0.8, ease: "power2.out" }, 1);
tl.to("#el-title h1", { opacity: 0, duration: 0.5 }, 4);
// REQUIRED: Register the timeline. The key MUST match data-composition-id.
window.__timelines = window.__timelines || {};
window.__timelines["my-video"] = tl;
</script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
# HyperFrames Core Changelog
## v0.1
Initial schema specification. Defines the foundational HTML-as-video format: compositions, clip types (video, image, audio, nested compositions), data attributes for timing and tracks, relative timing with offsets, the two-layer primitives + scripts model, and the GSAP timeline contract.
+332
View File
@@ -0,0 +1,332 @@
# HyperFrames Schema
Reference for generating and editing HyperFrames HTML compositions. This is your source of truth for how to author compositions.
## Overview
HyperFrames uses HTML as the source of truth for describing a video:
- **HTML clips** = video, image, audio, composition
- **Data attributes** = timing, metadata, styling
- **CSS** = positioning and appearance
- **GSAP timeline** = animations and playback sync
### Framework-Managed Behavior
The framework reads data attributes and automatically manages:
- **Primitive clip timeline entries** — the framework reads `data-start`, `data-duration`, and `data-track-index` from primitive clips and adds them to the composition's GSAP timeline. You do not manually add primitive clips to the timeline in scripts.
- **Media playback** (play, pause, seek) for `<video>` and `<audio>`
- **Clip lifecycle** — clips are **mounted** (made visible on screen) and **unmounted** (removed from screen) based on `data-start` and `data-duration`
- **Timeline synchronization** (keeping media in sync with the GSAP master timeline)
- **Media loading** — the framework waits for all media elements to load before resolving timing and starting playback
Mounting and unmounting controls **presence**, not appearance. A clip that is mounted is on screen; a clip that is unmounted is not. Transitions (fade in, slide in, etc.) are separate — they are animated in scripts and happen _after_ a clip is mounted or _before_ it is unmounted.
The framework does **not** handle transitions, effects, or visual animation — those are driven by GSAP in JavaScript.
Do not manually call `video.play()`, `video.pause()`, set `audio.currentTime`, or mount/unmount clips in scripts. The framework owns media playback and clip lifecycle. Animating visual properties like `opacity` or `transform` for transitions is fine — that's what scripts are for.
## Viewport
The root composition must declare its intended render dimensions using `data-width` and `data-height` attributes. Common sizes:
- **Landscape**: `data-width="1920" data-height="1080"`
- **Portrait**: `data-width="1080" data-height="1920"`
e.g.,
```html
<div id="main" data-composition-id="my-video" data-start="0" data-width="1920" data-height="1080">
<!-- clips -->
</div>
```
Every composition's container is full-screen within the viewport by default. The framework applies full-screen sizing to composition containers automatically.
To position or size individual clips (e.g., picture-in-picture, overlay placement), use standard CSS on the element.
## Compositions
A composition is the fundamental grouping unit in HyperFrames. Every clip — video, image, audio — must live inside a composition. The `index.html` file is itself a composition (the top-level one), and it can contain nested compositions within it. Any composition can be imported into another composition as a sub-composition — there is no special "root" type.
A composition carries the same core attributes as any other clip (`id`, `data-start`, `data-track-index`), so it can be placed and timed on a timeline just like a video or image. A composition's length is determined by its GSAP timeline — there is no `data-duration` on compositions. This means compositions can be nested: a composition clip inside another composition behaves like a self-contained video within the parent timeline.
```html
<div id="comp-1" data-composition-id="my-video" data-start="0" data-width="1920" data-height="1080">
<!-- Clips live inside the composition -->
<video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="el-2" data-start="el-1" data-duration="8" data-track-index="0" src="..."></video>
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." />
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..." />
<!-- Nested composition (e.g. a motion graphic or title sequence) -->
<div id="el-5" data-composition-id="intro-anim" data-start="0" data-track-index="3">
<!-- its own clips, timeline, and script -->
<script src="intro-anim.js"></script>
</div>
</div>
```
> Note: `data-width` and `data-height` are only required on the **root** composition. Nested compositions inherit the viewport dimensions.
## Clip Types
A clip is any discrete block on the timeline. We represent clips as HTML elements and apply data-attributes to describe them.
- `<video>` — Video clips, B-roll, A-roll
- `<img>` — Static images, overlays
- `<audio>` — Music, sound effects
- `<div data-composition-id="...">` — Nested compositions (animations, grouped sequences)
## HTML Attributes
### All Clips
- `id` — Unique identifier (e.g., "el-1")
- `data-start` — Start time in seconds, or a clip `id` reference. See [Relative Timing](#relative-timing).
- `data-duration` — Duration in seconds. Required for `<img>` clips. Optional for `<video>` and `<audio>` (defaults to the source media's full duration). Not used on compositions.
- `data-track-index` — Timeline track number. Tracks serve two purposes: they determine visual layering (higher tracks render in front) and they group clips into rows on the timeline. Clips on the same track **cannot overlap in time**.
### Media Clips (video, audio)
- `data-media-start` — (optional) Playback begins at this time in the source file, in seconds. Defaults to `0`.
### Composition Clips
- `data-composition-id` — Unique composition ID
> Compositions do **not** use `data-duration`. Their duration is determined by their GSAP timeline.
## Relative Timing
Instead of calculating absolute start times, a clip can reference another clip's `id` in its `data-start` attribute. This means "start when that clip ends." The referenced clip must be in the same composition and must have a known duration (either an explicit `data-duration` or an inferred duration from the source media).
### Basic Sequential Clips
```html
<video id="intro" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="main" data-start="intro" data-duration="20" data-track-index="0" src="..."></video>
<video id="outro" data-start="main" data-duration="5" data-track-index="0" src="..."></video>
```
`main` resolves to second 10, `outro` resolves to second 30. If `intro`'s duration changes to 15, `main` and `outro` shift automatically.
### Offsets (gaps and overlaps)
Add `+ N` or `- N` after the ID to offset from the end of the referenced clip:
```html
<!-- intro ends at 10. "intro + 2" = 10 + 2 = starts at second 12 (2s gap) -->
<video
id="scene-a"
data-start="intro + 2"
data-duration="20"
data-track-index="0"
src="..."
></video>
<!-- intro ends at 10. "intro - 0.5" = 10 - 0.5 = starts at second 9.5 (0.5s overlap for crossfade) -->
<!-- Different track because clips on the same track cannot overlap -->
<video
id="scene-b"
data-start="intro - 0.5"
data-duration="20"
data-track-index="1"
src="..."
></video>
```
### Rules
- **Same composition only** — references resolve within the clip's parent composition
- **No circular references** — A cannot start after B if B starts after A
- **Referenced clip must have a known duration** — the system needs a known end time to resolve the reference (either explicit `data-duration` or inferred from source media)
- **Parsing** — if the value is a valid number, it is absolute seconds; otherwise it is parsed as `<id>`, `<id> + <number>`, or `<id> - <number>`
## Video Clips
Full-screen or positioned video clips. Videos sync their playback to the timeline position.
```html
<video
id="el-1"
data-start="0"
data-duration="15"
data-track-index="0"
src="./assets/video.mp4"
></video>
```
- `data-media-start` — Playback begins at this time in the source video file (seconds). Default: `0`.
- `data-duration` — (optional) How long the clip occupies on the timeline, in seconds. Playback runs from `data-media-start` for up to `data-duration` seconds. If the source media runs out before `data-duration` elapses, playback naturally stops (the clip remains mounted showing the last frame). If omitted, defaults to the remaining duration of the source file from `data-media-start`.
## Image Clips
Static images that appear for a duration.
```html
<img id="el-2" data-start="5" data-duration="4" data-track-index="1" src="./assets/video.mp4" />
```
## Audio Clips
Background music or sound effects. Audio clips are invisible.
```html
<audio
id="el-4"
data-start="0"
data-duration="30"
data-track-index="2"
src="./assets/music.mp3"
></audio>
```
- `data-media-start` — Playback begins at this time in the source audio file (seconds). Default: `0`.
- `data-duration` — (optional) How long the clip occupies on the timeline, in seconds. Playback runs from `data-media-start` for up to `data-duration` seconds. If the source media runs out before `data-duration` elapses, playback naturally stops (the clip remains mounted but silent). If omitted, defaults to the remaining duration of the source file from `data-media-start`.
## Two Layers: Primitives and Scripts
Every composition — master or sub — has the same two layers:
- **HTML** — primitive clips (`video`, `img`, `audio`, nested `div[data-composition-id]`). This is the declarative structure: what plays, when, and on which track.
- **Script** — effects, transitions, dynamic DOM, canvas, SVG — creative animation and visuals via GSAP. Scripts do **not** control media playback or clip visibility; the framework handles those via data attributes.
Both layers are available to every composition. The schema defines the primitives and the timeline contract; scripts handle visual creativity on top of that.
> **Warning:** Never use scripts to play/pause/seek media elements or to show/hide clips based on timing. The framework does this automatically from `data-start`, `data-duration`, and `data-media-start`. Scripts that duplicate this behavior will conflict with the framework.
### Script Isolation
Each composition's script is scoped to that composition. For sub-compositions, external JS files are the natural way to keep them self-contained and reusable:
```html
<div id="el-5" data-composition-id="intro-anim" data-start="0" data-track-index="2">
<script src="intro-anim.js"></script>
</div>
```
Inline scripts are fine when the script belongs to that composition. The JS file itself is the documentation for what a composition does — the schema doesn't need to describe its internals.
The only required file is `index.html`. Every composition must have at least a script to create and register its GSAP timeline.
### Top-Level Composition
The top-level composition is the `index.html` entry point. It acts as the conductor — sequencing clips and placing sub-composition timelines into an overall master timeline. It can technically do anything in its script, but its primary purpose is high-level orchestration. Any composition can serve as a top-level composition or be nested into another — there is no structural difference.
```html
<div id="comp-1" data-composition-id="my-video" data-start="0" data-width="1920" data-height="1080">
<video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="el-2" data-start="el-1" data-duration="8" data-track-index="0" src="..."></video>
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." />
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..." />
<div id="el-5" data-composition-id="intro-anim" data-start="0" data-track-index="3">
<script src="intro-anim.js"></script>
</div>
<script>
// Just register the timeline - framework auto-nests sub-compositions
const tl = gsap.timeline({ paused: true });
window.__timelines["my-video"] = tl;
</script>
</div>
```
### Sub-Compositions
Sub-compositions are a spectrum. One might simply group a few primitive clips. Another might be a fully custom program with its own HTML, CSS, and JavaScript — creating, animating, and destroying DOM however it sees fit. There are no categories or constraints on what a sub-composition does internally. All compositions default to full-screen within the viewport. The only rule: it must be driven by a GSAP timeline and export it.
## Wrapping Dynamic Content in Compositions
**Critical Rule: All visual content must live inside a composition with data attributes to appear in the timeline.**
When you have dynamic or script-animated content (captions, emojis, overlays, text animations), wrap them in a composition element with `data-start`, `data-duration` (or let the timeline determine duration), and `data-track-index`. The children inside can be freely created and animated via JavaScript—they don't need individual data attributes.
### Wrong: Dynamic content outside a composition
```html
<!-- BAD: captions-container is not a composition - won't appear in timeline -->
<div id="ui-layer">
<div id="captions-container">
<!-- Dynamically created caption groups via JS -->
</div>
<div class="emoji" id="emoji-1">🤩</div>
</div>
<script>
// These animations work visually but elements don't appear in timeline
tl.to(".caption-group", { opacity: 1 }, 0.5);
tl.to("#emoji-1", { scale: 1.2 }, 2);
</script>
```
### Correct: Dynamic content wrapped in compositions
```html
<!-- GOOD: Each logical group is a composition that appears in timeline -->
<div id="captions-comp" data-composition-id="captions" data-start="0" data-track-index="5">
<!-- Children created/animated by script - no data attributes needed -->
<div id="captions-container"></div>
<script>
const captionTL = gsap.timeline({ paused: true });
// Dynamically create and animate caption groups...
window.__timelines["captions"] = captionTL;
</script>
</div>
<div id="emojis-comp" data-composition-id="emojis" data-start="0" data-track-index="6">
<div class="emoji" id="emoji-1">🤩</div>
<div class="emoji" id="emoji-2">🏔️</div>
<script>
const emojiTL = gsap.timeline({ paused: true });
emojiTL.to("#emoji-1", { opacity: 1, scale: 1.2 }, 2);
emojiTL.to("#emoji-2", { opacity: 1, scale: 1.2 }, 4);
window.__timelines["emojis"] = emojiTL;
</script>
</div>
```
### When to create separate compositions
- **Captions**: One composition for all captions, script manages word groups
- **Emojis/Stickers**: One composition for the emoji layer
- **Hooks/Titles**: One composition per distinct title sequence
- **Overlays**: Group related overlays into compositions by purpose
The composition appears in the timeline with its start time and duration (determined by its GSAP timeline). Everything inside is managed by the script but inherits the composition's timeline position.
## Timeline Contract
The framework initializes `window.__timelines = {}` before any scripts run. Every composition **must** have a script that creates a GSAP timeline and registers it:
```js
const tl = gsap.timeline({ paused: true });
// ... add tweens, nested timelines, etc.
window.__timelines["<data-composition-id>"] = tl;
```
### Rules
- **Every composition needs a script** — at minimum, to create and register its timeline. A composition without a script has no timeline and cannot participate in the hierarchy.
- **All timelines start paused** — create timelines with `{ paused: true }`. The top-level timeline is controlled externally by the frontend player or renderer.
- **Framework auto-nests sub-timelines** — you do **not** need to manually add sub-composition timelines to the master timeline. The framework automatically nests any timeline registered in `window.__timelines` into its parent based on the composition's `data-start` attribute.
- **Duration comes from the timeline** — a composition's duration is `tl.duration()`. The timeline is the sole source of truth; there is no `data-duration` on compositions.
### What NOT to do
```js
// UNNECESSARY - the framework does this automatically
if (window.__timelines["captions"]) {
masterTL.add(window.__timelines["captions"], 0);
}
```
Just register your timeline in `window.__timelines` and the framework handles the rest.
## Output Checklist
- [ ] Root composition has `data-width` and `data-height` attributes
- [ ] `window.__timelines` given all compositions' timelines
- [ ] Complex/dynamic animations are handled by scripts in compositions
+447
View File
@@ -0,0 +1,447 @@
{
"name": "@hyperframes/core",
"version": "0.7.55",
"description": "",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
"directory": "packages/core"
},
"files": [
"dist/**/*.js",
"dist/**/*.d.ts",
"dist/**/*.d.ts.map",
"dist/**/*.js.map",
"!dist/hyperframe.runtime.mjs",
"docs",
"schemas",
"README.md"
],
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"node": "./dist/index.js",
"import": "./src/index.ts",
"types": "./src/index.ts"
},
"./package.json": "./package.json",
"./beats": {
"bun": "./src/beats/index.ts",
"node": "./dist/beats/index.js",
"import": "./src/beats/index.ts",
"types": "./src/beats/index.ts"
},
"./html-attr-safety": {
"bun": "./src/utils/htmlAttrSafety.ts",
"node": "./dist/utils/htmlAttrSafety.js",
"import": "./src/utils/htmlAttrSafety.ts",
"types": "./src/utils/htmlAttrSafety.ts"
},
"./editing": {
"bun": "./src/editing/affordances.ts",
"node": "./dist/editing/affordances.js",
"import": "./src/editing/affordances.ts",
"types": "./src/editing/affordances.ts"
},
"./variables": {
"bun": "./src/variables.ts",
"node": "./dist/variables.js",
"import": "./src/variables.ts",
"types": "./src/variables.ts"
},
"./slideshow": {
"bun": "./src/slideshow/index.ts",
"node": "./dist/slideshow/index.js",
"import": "./src/slideshow/index.ts",
"types": "./src/slideshow/index.ts"
},
"./generators": {
"bun": "./src/generators/hyperframes.ts",
"node": "./dist/generators/hyperframes.js",
"import": "./src/generators/hyperframes.ts",
"types": "./src/generators/hyperframes.ts"
},
"./lint": {
"bun": "./src/lint/index.ts",
"node": "./dist/lint/index.js",
"import": "./src/lint/index.ts",
"types": "./src/lint/index.ts"
},
"./compiler": {
"bun": "./src/compiler/index.ts",
"node": "./dist/compiler/index.js",
"import": "./src/compiler/index.ts",
"types": "./src/compiler/index.ts"
},
"./color-grading": {
"bun": "./src/colorGrading.ts",
"node": "./dist/colorGrading.js",
"import": "./src/colorGrading.ts",
"types": "./src/colorGrading.ts"
},
"./color-luts": {
"bun": "./src/colorLuts.ts",
"node": "./dist/colorLuts.js",
"import": "./src/colorLuts.ts",
"types": "./src/colorLuts.ts"
},
"./storyboard": {
"bun": "./src/storyboard/index.ts",
"node": "./dist/storyboard/index.js",
"import": "./src/storyboard/index.ts",
"types": "./src/storyboard/index.ts"
},
"./runtime": "./dist/hyperframe.runtime.iife.js",
"./runtime/clipTree": {
"bun": "./src/runtime/clipTree.ts",
"node": "./dist/runtime/clipTree.js",
"import": "./src/runtime/clipTree.ts",
"types": "./src/runtime/clipTree.ts"
},
"./runtime/start-expression": {
"bun": "./src/runtime/startExpression.ts",
"node": "./dist/runtime/startExpression.js",
"import": "./src/runtime/startExpression.ts",
"types": "./src/runtime/startExpression.ts"
},
"./compiler/html-document": {
"bun": "./src/compiler/htmlDocument.ts",
"node": "./dist/compiler/htmlDocument.js",
"import": "./src/compiler/htmlDocument.ts",
"types": "./src/compiler/htmlDocument.ts"
},
"./runtime/position-edits": {
"bun": "./src/runtime/positionEdits.ts",
"node": "./dist/runtime/positionEdits.js",
"import": "./src/runtime/positionEdits.ts",
"types": "./src/runtime/positionEdits.ts"
},
"./runtime/position-edits-render": {
"bun": "./src/generated/position-edits-render-inline.ts",
"node": "./dist/generated/position-edits-render-inline.js",
"import": "./src/generated/position-edits-render-inline.ts",
"types": "./src/generated/position-edits-render-inline.ts"
},
"./runtime/lottie-readiness": {
"bun": "./src/lottieReadiness.ts",
"node": "./dist/lottieReadiness.js",
"import": "./src/lottieReadiness.ts",
"types": "./src/lottieReadiness.ts"
},
"./studio-api": {
"bun": "./src/studio-api/index.ts",
"node": "./dist/studio-api/index.js",
"import": "./src/studio-api/index.ts",
"types": "./src/studio-api/index.ts"
},
"./studio-api/screenshot-clip": {
"bun": "./src/studio-api/helpers/screenshotClip.ts",
"node": "./dist/studio-api/helpers/screenshotClip.js",
"import": "./src/studio-api/helpers/screenshotClip.ts",
"types": "./src/studio-api/helpers/screenshotClip.ts"
},
"./studio-api/manual-edits-render-script": {
"bun": "./src/studio-api/helpers/manualEditsRenderScript.ts",
"node": "./dist/studio-api/helpers/manualEditsRenderScript.js",
"import": "./src/studio-api/helpers/manualEditsRenderScript.ts",
"types": "./src/studio-api/helpers/manualEditsRenderScript.ts"
},
"./studio-api/studio-motion-render-script": {
"bun": "./src/studio-api/helpers/studioMotionRenderScript.ts",
"node": "./dist/studio-api/helpers/studioMotionRenderScript.js",
"import": "./src/studio-api/helpers/studioMotionRenderScript.ts",
"types": "./src/studio-api/helpers/studioMotionRenderScript.ts"
},
"./studio-api/draft-markers": {
"bun": "./src/studio-api/helpers/draftMarkers.ts",
"node": "./dist/studio-api/helpers/draftMarkers.js",
"import": "./src/studio-api/helpers/draftMarkers.ts",
"types": "./src/studio-api/helpers/draftMarkers.ts"
},
"./studio-api/finite-mutation": {
"bun": "./src/studio-api/helpers/finiteMutation.ts",
"node": "./dist/studio-api/helpers/finiteMutation.js",
"import": "./src/studio-api/helpers/finiteMutation.ts",
"types": "./src/studio-api/helpers/finiteMutation.ts"
},
"./text": {
"bun": "./src/text/index.ts",
"node": "./dist/text/index.js",
"import": "./src/text/index.ts",
"types": "./src/text/index.ts"
},
"./registry": {
"bun": "./src/registry/index.ts",
"node": "./dist/registry/index.js",
"import": "./src/registry/index.ts",
"types": "./src/registry/index.ts"
},
"./media-volume-envelope": {
"bun": "./src/runtime/mediaVolumeEnvelope.ts",
"node": "./dist/runtime/mediaVolumeEnvelope.js",
"import": "./src/runtime/mediaVolumeEnvelope.ts",
"types": "./src/runtime/mediaVolumeEnvelope.ts"
},
"./hf-ids": {
"bun": "./src/parsers/hfIds.ts",
"node": "./dist/parsers/hfIds.js",
"import": "./src/parsers/hfIds.ts",
"types": "./src/parsers/hfIds.ts"
},
"./gsap-parser": {
"bun": "./src/parsers/gsapParserExports.ts",
"node": "./dist/parsers/gsapParserExports.js",
"import": "./src/parsers/gsapParserExports.ts",
"types": "./src/parsers/gsapParserExports.ts"
},
"./gsap-parser-acorn": {
"bun": "./src/parsers/gsapParserAcorn.ts",
"node": "./dist/parsers/gsapParserAcorn.js",
"import": "./src/parsers/gsapParserAcorn.ts",
"types": "./src/parsers/gsapParserAcorn.ts"
},
"./gsap-writer-acorn": {
"bun": "./src/parsers/gsapWriterAcorn.ts",
"node": "./dist/parsers/gsapWriterAcorn.js",
"import": "./src/parsers/gsapWriterAcorn.ts",
"types": "./src/parsers/gsapWriterAcorn.ts"
},
"./gsap-constants": {
"bun": "./src/parsers/gsapConstants.ts",
"node": "./dist/parsers/gsapConstants.js",
"import": "./src/parsers/gsapConstants.ts",
"types": "./src/parsers/gsapConstants.ts"
},
"./spring-ease": {
"bun": "./src/parsers/springEase.ts",
"node": "./dist/parsers/springEase.js",
"import": "./src/parsers/springEase.ts",
"types": "./src/parsers/springEase.ts"
},
"./fonts/aliases": {
"bun": "./src/fonts/aliases.ts",
"node": "./dist/fonts/aliases.js",
"import": "./src/fonts/aliases.ts",
"types": "./src/fonts/aliases.ts"
},
"./fonts/system-locator": {
"bun": "./src/fonts/systemFontLocator.ts",
"node": "./dist/fonts/systemFontLocator.js",
"import": "./src/fonts/systemFontLocator.ts",
"types": "./src/fonts/systemFontLocator.ts"
},
"./figma": {
"bun": "./src/figma/index.ts",
"node": "./dist/figma/index.js",
"import": "./src/figma/index.ts",
"types": "./src/figma/index.ts"
},
"./schemas/registry.json": "./schemas/registry.json",
"./schemas/registry-item.json": "./schemas/registry-item.json"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./package.json": "./package.json",
"./beats": {
"import": "./dist/beats/index.js",
"types": "./dist/beats/index.d.ts"
},
"./html-attr-safety": {
"import": "./dist/utils/htmlAttrSafety.js",
"types": "./dist/utils/htmlAttrSafety.d.ts"
},
"./editing": {
"import": "./dist/editing/affordances.js",
"types": "./dist/editing/affordances.d.ts"
},
"./figma": {
"import": "./dist/figma/index.js",
"types": "./dist/figma/index.d.ts"
},
"./generators": {
"import": "./dist/generators/hyperframes.js",
"types": "./dist/generators/hyperframes.d.ts"
},
"./lint": {
"import": "./dist/lint/index.js",
"types": "./dist/lint/index.d.ts"
},
"./variables": {
"import": "./dist/variables.js",
"types": "./dist/variables.d.ts"
},
"./slideshow": {
"import": "./dist/slideshow/index.js",
"types": "./dist/slideshow/index.d.ts"
},
"./compiler": {
"import": "./dist/compiler/index.js",
"types": "./dist/compiler/index.d.ts"
},
"./color-grading": {
"import": "./dist/colorGrading.js",
"types": "./dist/colorGrading.d.ts"
},
"./color-luts": {
"import": "./dist/colorLuts.js",
"types": "./dist/colorLuts.d.ts"
},
"./storyboard": {
"import": "./dist/storyboard/index.js",
"types": "./dist/storyboard/index.d.ts"
},
"./runtime": "./dist/hyperframe.runtime.iife.js",
"./runtime/clipTree": {
"import": "./dist/runtime/clipTree.js",
"types": "./dist/runtime/clipTree.d.ts"
},
"./runtime/start-expression": {
"import": "./dist/runtime/startExpression.js",
"types": "./dist/runtime/startExpression.d.ts"
},
"./compiler/html-document": {
"import": "./dist/compiler/htmlDocument.js",
"types": "./dist/compiler/htmlDocument.d.ts"
},
"./runtime/position-edits": {
"import": "./dist/runtime/positionEdits.js",
"types": "./dist/runtime/positionEdits.d.ts"
},
"./runtime/position-edits-render": {
"import": "./dist/generated/position-edits-render-inline.js",
"types": "./dist/generated/position-edits-render-inline.d.ts"
},
"./runtime/lottie-readiness": {
"import": "./dist/lottieReadiness.js",
"types": "./dist/lottieReadiness.d.ts"
},
"./studio-api": {
"import": "./dist/studio-api/index.js",
"types": "./dist/studio-api/index.d.ts"
},
"./studio-api/screenshot-clip": {
"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"
},
"./studio-api/draft-markers": {
"import": "./dist/studio-api/helpers/draftMarkers.js",
"types": "./dist/studio-api/helpers/draftMarkers.d.ts"
},
"./studio-api/finite-mutation": {
"import": "./dist/studio-api/helpers/finiteMutation.js",
"types": "./dist/studio-api/helpers/finiteMutation.d.ts"
},
"./text": {
"import": "./dist/text/index.js",
"types": "./dist/text/index.d.ts"
},
"./registry": {
"import": "./dist/registry/index.js",
"types": "./dist/registry/index.d.ts"
},
"./media-volume-envelope": {
"import": "./dist/runtime/mediaVolumeEnvelope.js",
"types": "./dist/runtime/mediaVolumeEnvelope.d.ts"
},
"./hf-ids": {
"import": "./dist/parsers/hfIds.js",
"types": "./dist/parsers/hfIds.d.ts"
},
"./gsap-parser": {
"import": "./dist/parsers/gsapParserExports.js",
"types": "./dist/parsers/gsapParserExports.d.ts"
},
"./gsap-parser-acorn": {
"import": "./dist/parsers/gsapParserAcorn.js",
"types": "./dist/parsers/gsapParserAcorn.d.ts"
},
"./gsap-writer-acorn": {
"import": "./dist/parsers/gsapWriterAcorn.js",
"types": "./dist/parsers/gsapWriterAcorn.d.ts"
},
"./gsap-constants": {
"import": "./dist/parsers/gsapConstants.js",
"types": "./dist/parsers/gsapConstants.d.ts"
},
"./spring-ease": {
"import": "./dist/parsers/springEase.js",
"types": "./dist/parsers/springEase.d.ts"
},
"./fonts/aliases": {
"import": "./dist/fonts/aliases.js",
"types": "./dist/fonts/aliases.d.ts"
},
"./fonts/system-locator": {
"import": "./dist/fonts/systemFontLocator.js",
"types": "./dist/fonts/systemFontLocator.d.ts"
},
"./schemas/registry.json": "./schemas/registry.json",
"./schemas/registry-item.json": "./schemas/registry-item.json"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"scripts": {
"build": "bun run build:hyperframes-runtime && bun run build:position-edits-render && tsc && tsx scripts/rewrite-esm-extensions.ts",
"test": "bun run check:position-edits-render && vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:runtime-coverage": "vitest run --coverage src/runtime",
"typecheck": "tsc --noEmit && bun run typecheck:runtime",
"typecheck:runtime": "tsc --noEmit -p tsconfig.runtime.json",
"lint:runtime-preview-guards": "bun scripts/lint-runtime-preview-guards.ts",
"build:hyperframes-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts",
"build:position-edits-render": "tsx scripts/build-position-edits-render.ts",
"check:position-edits-render": "bun run build:position-edits-render && git diff --exit-code -- src/generated/position-edits-render-inline.ts",
"build:hyperframes-runtime:modular": "SANDBOX_RUNTIME_VARIANT=modular tsx scripts/build-hyperframes-runtime-artifact.ts",
"build:hyperframe-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts",
"test:hyperframe-runtime-contract": "tsx scripts/test-hyperframe-runtime-contract.ts",
"test:hyperframe-runtime-behavior": "tsx scripts/test-hyperframe-runtime-behavior.ts",
"test:hyperframe-runtime-seek": "tsx scripts/test-hyperframe-runtime-seek.ts",
"test:hyperframe-runtime-duration-guards": "tsx scripts/test-hyperframe-runtime-duration-guards.ts",
"test:hyperframe-runtime-parity": "tsx scripts/test-hyperframe-runtime-parity.ts",
"test:hyperframe-runtime-security": "tsx scripts/test-hyperframe-runtime-security.ts",
"test:hyperframe-linter": "bun scripts/test-hyperframe-linter.ts",
"test:hyperframe-runtime-ci": "bun run typecheck:runtime && bun run lint:runtime-preview-guards && bun run build:hyperframes-runtime && bun run test:hyperframe-runtime-contract && bun run test:hyperframe-runtime-behavior && bun run test:hyperframe-runtime-seek && bun run test:hyperframe-runtime-duration-guards && bun run test:hyperframe-runtime-parity && bun run test:hyperframe-runtime-security && bun run test:runtime-coverage && bun run test:hyperframe-linter",
"check:hyperframe-html": "bun scripts/check-hyperframe-static.ts",
"debug:timeline": "tsx scripts/debug-timeline.ts",
"prepublishOnly": "echo skip"
},
"dependencies": {
"@chenglou/pretext": "^0.0.5",
"@hyperframes/lint": "workspace:*",
"@hyperframes/parsers": "workspace:*",
"@hyperframes/studio-server": "workspace:*",
"bpm-detective": "^2.0.5",
"linkedom": "^0.18.12",
"postcss": "^8.5.8"
},
"devDependencies": {
"@types/jsdom": "^28.0.0",
"@types/node": "^25.0.10",
"@vitest/coverage-v8": "^3.2.4",
"jsdom": "^29.0.0",
"tsx": "^4.21.0",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
},
"optionalDependencies": {
"esbuild": "^0.25.12"
}
}
+149
View File
@@ -0,0 +1,149 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hyperframes.heygen.com/schema/registry-item.json",
"title": "Hyperframes Registry Item",
"description": "Manifest for a single distributable item (example, block, or component).",
"type": "object",
"required": ["name", "type", "title", "description", "files"],
"properties": {
"$schema": {
"type": "string",
"format": "uri"
},
"name": {
"type": "string",
"pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
"description": "Item name in kebab-case, must start and end with alphanumeric."
},
"type": {
"type": "string",
"enum": ["hyperframes:example", "hyperframes:block", "hyperframes:component"]
},
"title": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string",
"minLength": 1
},
"tags": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"author": {
"type": "string",
"minLength": 1
},
"authorUrl": {
"type": "string",
"format": "uri"
},
"sourcePrompt": {
"type": "string",
"minLength": 1
},
"license": {
"type": "string",
"minLength": 1,
"description": "SPDX license identifier (e.g. \"Apache-2.0\", \"MIT\")."
},
"minCliVersion": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$",
"description": "Minimum `hyperframes` CLI version required to install this item."
},
"deprecated": {
"type": "string",
"minLength": 1,
"description": "If set, the item is deprecated; the value is the reason or migration note."
},
"dimensions": {
"type": "object",
"required": ["width", "height"],
"additionalProperties": false,
"properties": {
"width": { "type": "integer", "minimum": 1 },
"height": { "type": "integer", "minimum": 1 }
}
},
"duration": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Duration in seconds. Must be > 0."
},
"registryDependencies": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
}
},
"files": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["path", "target", "type"],
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"minLength": 1,
"description": "Source path, relative to registry-item.json."
},
"target": {
"type": "string",
"minLength": 1,
"description": "Destination path in the user's project, relative to project root. Must not traverse outside the project (no `..` segments, no absolute paths).",
"not": {
"anyOf": [
{ "pattern": "(^|[/\\\\])\\.\\.([/\\\\]|$)" },
{ "pattern": "^[/\\\\]" },
{ "pattern": "^[A-Za-z]:[/\\\\]" }
]
}
},
"type": {
"type": "string",
"enum": [
"hyperframes:composition",
"hyperframes:asset",
"hyperframes:snippet",
"hyperframes:style",
"hyperframes:timeline"
]
}
}
}
},
"preview": {
"type": "object",
"additionalProperties": false,
"properties": {
"video": { "type": "string" },
"poster": { "type": "string" }
}
},
"relatedSkill": {
"type": "string",
"minLength": 1
}
},
"allOf": [
{
"if": {
"required": ["type"],
"properties": { "type": { "const": "hyperframes:component" } }
},
"then": {
"not": {
"anyOf": [{ "required": ["dimensions"] }, { "required": ["duration"] }]
}
},
"else": {
"required": ["dimensions", "duration"]
}
}
]
}
+45
View File
@@ -0,0 +1,45 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hyperframes.heygen.com/schema/registry.json",
"title": "Hyperframes Registry Manifest",
"description": "Top-level manifest describing all items in a Hyperframes registry.",
"type": "object",
"required": ["name", "homepage", "items"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string",
"format": "uri"
},
"name": {
"type": "string",
"minLength": 1,
"description": "Registry name (e.g. \"hyperframes\")."
},
"homepage": {
"type": "string",
"format": "uri",
"description": "Registry homepage URL."
},
"items": {
"type": "array",
"description": "Items in this registry. Each entry is a shorthand reference; the full item manifest lives at <type-dir>/<name>/registry-item.json.",
"items": {
"type": "object",
"required": ["name", "type"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
"description": "Item name in kebab-case, must start and end with alphanumeric."
},
"type": {
"type": "string",
"enum": ["hyperframes:example", "hyperframes:block", "hyperframes:component"]
}
}
}
}
}
}
@@ -0,0 +1,94 @@
import { createHash } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { buildSync } from "esbuild";
import {
HYPERFRAME_RUNTIME_ARTIFACTS,
HYPERFRAME_RUNTIME_CONTRACT,
loadHyperframeRuntimeSource,
} from "../src/inline-scripts/hyperframe";
const thisDir = dirname(fileURLToPath(import.meta.url));
const distDir = resolve(thisDir, "../dist");
const iifePath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.iife);
const esmPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.esm);
const manifestPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.manifest);
const runtimeSourceRaw = loadHyperframeRuntimeSource();
if (runtimeSourceRaw === null) {
throw new Error("Cannot build runtime artifact: entry.ts not found at expected path");
}
const runtimeSource = `${runtimeSourceRaw}\n`;
const runtimeSha256 = createHash("sha256").update(runtimeSource, "utf8").digest("hex");
const buildId = process.env.HYPERFRAME_RUNTIME_BUILD_ID?.trim() || "dev";
const runtimeEntryPath = resolve(thisDir, "../src/runtime/entry.ts");
const esmBuild = buildSync({
entryPoints: [runtimeEntryPath],
bundle: true,
write: false,
platform: "browser",
format: "esm",
target: ["es2020"],
minify: true,
legalComments: "none",
});
const esmSource = `${esmBuild.outputFiles[0]?.text ?? ""}\n`;
const manifest = {
version: process.env.HYPERFRAME_RUNTIME_VERSION?.trim() || "0.1.0",
buildId,
sha256: runtimeSha256,
artifacts: {
iife: HYPERFRAME_RUNTIME_ARTIFACTS.iife,
esm: HYPERFRAME_RUNTIME_ARTIFACTS.esm,
},
contract: HYPERFRAME_RUNTIME_CONTRACT,
};
mkdirSync(distDir, { recursive: true });
writeFileSync(iifePath, runtimeSource, "utf8");
writeFileSync(esmPath, esmSource, "utf8");
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
// ── Generate src/generated/runtime-inline.ts ──────────────────────────────
// This file is compiled by tsc into dist/ and provides the production-safe
// getHyperframeRuntimeScript() that returns the IIFE as a string constant —
// no esbuild, no file I/O, no import.meta.url arithmetic.
const generatedDir = resolve(thisDir, "../src/generated");
mkdirSync(generatedDir, { recursive: true });
const inlineModulePath = resolve(generatedDir, "runtime-inline.ts");
const escapedSource = JSON.stringify(runtimeSourceRaw);
writeFileSync(
inlineModulePath,
[
"// AUTO-GENERATED by scripts/build-hyperframes-runtime-artifact.ts — do not edit",
`const RUNTIME_IIFE: string = ${escapedSource};`,
"",
"/**",
" * Returns the pre-built hyperframe runtime IIFE as a string constant.",
" * This is the production-safe path: no esbuild, no file I/O,",
" * no import.meta.url arithmetic.",
" */",
"export function getHyperframeRuntimeScript(): string {",
" return RUNTIME_IIFE;",
"}",
"",
].join("\n"),
"utf8",
);
console.log(
JSON.stringify({
event: "hyperframe_runtime_artifacts_generated",
buildId,
distDir,
iifePath,
esmPath,
manifestPath,
inlineModulePath,
sourceBytes: Buffer.byteLength(runtimeSource, "utf8"),
esmBytes: Buffer.byteLength(esmSource, "utf8"),
sha256: runtimeSha256,
}),
);
@@ -0,0 +1,52 @@
/** Build the injectable position-edits render artifact from the canonical runtime. */
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { buildSync } from "esbuild";
import { execFileSync } from "node:child_process";
const thisDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(thisDir, "..");
const entry = resolve(repoRoot, "stubs/position-edits-render-entry.ts");
const generatedDir = resolve(repoRoot, "src/generated");
const outPath = resolve(generatedDir, "position-edits-render-inline.ts");
const result = buildSync({
entryPoints: [entry],
bundle: true,
write: false,
platform: "browser",
format: "iife",
target: ["es2020"],
minify: true,
legalComments: "none",
});
const iife = result.outputFiles[0]?.text ?? "";
if (!iife) throw new Error("esbuild produced no output for position-edits-render-entry.ts");
mkdirSync(generatedDir, { recursive: true });
writeFileSync(
outPath,
[
"// AUTO-GENERATED by scripts/build-position-edits-render.ts - do not edit",
`const POSITION_EDITS_RENDER_IIFE: string = ${JSON.stringify(iife)};`,
"",
"/** Returns the pre-built position-edits render IIFE as a string constant. */",
"export function getPositionEditsRenderScript(): string {",
" return POSITION_EDITS_RENDER_IIFE;",
"}",
"",
].join("\n"),
"utf8",
);
try {
execFileSync("bun", ["x", "oxfmt", outPath], { stdio: "ignore" });
} catch {
// Formatting is best effort when the generator runs in a minimal environment.
}
console.log(
JSON.stringify({ event: "position_edits_render_generated", outPath, bytes: iife.length }),
);
@@ -0,0 +1,73 @@
import fs from "node:fs";
import path from "node:path";
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/lint";
function formatCounts(result: HyperframeLintResult): string {
const parts = [`${result.warningCount} warning${result.warningCount === 1 ? "" : "s"}`];
if (result.infoCount > 0) {
parts.push(`${result.infoCount} info${result.infoCount === 1 ? "" : "s"}`);
}
return parts.join(", ");
}
function formatHumanOutput(result: HyperframeLintResult, resolvedPath: string): string {
const counts = result.ok
? formatCounts(result)
: `${result.errorCount} error${result.errorCount === 1 ? "" : "s"}, ${formatCounts(result)}`;
const lines = [
result.ok ? `PASS ${resolvedPath} (${counts})` : `FAIL ${resolvedPath} (${counts})`,
];
for (const finding of result.findings) {
lines.push(`- [${finding.severity.toUpperCase()}] ${finding.code}: ${finding.message}`);
if (finding.selector) {
lines.push(` selector: ${finding.selector}`);
}
if (finding.elementId) {
lines.push(` elementId: ${finding.elementId}`);
}
if (finding.fixHint) {
lines.push(` fix: ${finding.fixHint}`);
}
}
return lines.join("\n");
}
async function main() {
const args = process.argv.slice(2);
const normalizedArgs = args[0] === "--" ? args.slice(1) : args;
const jsonOutput = normalizedArgs.includes("--json");
const positionalArgs = normalizedArgs.filter((arg) => arg !== "--json");
const inputPath = positionalArgs[0];
if (!inputPath) {
console.error(
"Usage: bun run check:hyperframe-html [--json] <path-to-html>\nExample: bun run check:hyperframe-html core/src/tests/broken-video.html",
);
process.exit(2);
}
const resolvedPath = path.resolve(process.cwd(), inputPath);
if (!fs.existsSync(resolvedPath)) {
console.error(`File not found: ${resolvedPath}`);
process.exit(2);
}
const html = fs.readFileSync(resolvedPath, "utf-8");
const result = await lintHyperframeHtml(html, { filePath: resolvedPath });
if (jsonOutput) {
console.log(JSON.stringify(result, null, 2));
process.exit(result.ok ? 0 : 1);
}
if (result.ok) {
console.log(formatHumanOutput(result, resolvedPath));
process.exit(0);
}
console.error(formatHumanOutput(result, resolvedPath));
process.exit(1);
}
main();
+318
View File
@@ -0,0 +1,318 @@
import fs from "node:fs";
import path from "node:path";
type AttrMap = Record<string, string>;
type ParsedTag = {
tagName: string;
attrs: AttrMap;
raw: string;
};
type TimelineClip = {
id: string | null;
label: string;
start: number;
duration: number;
track: number;
kind: "video" | "audio" | "image" | "element";
tagName: string | null;
compositionId: string | null;
compositionSrc: string | null;
assetUrl: string | null;
durationSource: "deterministic" | "fallback";
};
type TimelinePayload = {
source: "hf-preview";
type: "timeline";
durationInFrames: number;
clips: TimelineClip[];
scenes: [];
compositionWidth: number;
compositionHeight: number;
};
function parseNum(value: string | null | undefined): number | null {
if (value == null) return null;
const parsed = Number.parseFloat(String(value));
return Number.isFinite(parsed) ? parsed : null;
}
function parseArgs() {
const args = process.argv.slice(2);
const normalizedArgs = args[0] === "--" ? args.slice(1) : args;
const inputPath = normalizedArgs[0];
let forcedFps: number | null = null;
let forcedMaxDuration: number | null = null;
for (let i = 1; i < normalizedArgs.length; i += 1) {
const arg = normalizedArgs[i];
if (arg === "--fps" && normalizedArgs[i + 1]) {
forcedFps = parseNum(normalizedArgs[i + 1]);
i += 1;
continue;
}
if (arg === "--max-duration" && normalizedArgs[i + 1]) {
forcedMaxDuration = parseNum(normalizedArgs[i + 1]);
i += 1;
}
}
return { inputPath, forcedFps, forcedMaxDuration };
}
function parseAttributes(rawAttrs: string): AttrMap {
const attrs: AttrMap = {};
const attrRegex = /([^\s=/>]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/g;
let match: RegExpExecArray | null = null;
while (true) {
match = attrRegex.exec(rawAttrs);
if (!match) break;
const key = match[1];
const value = match[3] ?? match[4] ?? match[5] ?? "";
attrs[key] = value;
}
return attrs;
}
function parseTags(html: string): ParsedTag[] {
const tags: ParsedTag[] = [];
const tagRegex = /<([a-zA-Z][\w:-]*)([^>]*)>/g;
let match: RegExpExecArray | null = null;
while (true) {
match = tagRegex.exec(html);
if (!match) break;
const raw = match[0];
if (raw.startsWith("</") || raw.startsWith("<!")) continue;
const tagName = String(match[1] || "").toLowerCase();
if (!tagName) continue;
tags.push({
tagName,
raw,
attrs: parseAttributes(match[2] || ""),
});
}
return tags;
}
function extractWindowNumber(html: string, key: string): number | null {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`${escapedKey}\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)`, "i");
const match = html.match(regex);
if (!match) return null;
return parseNum(match[1]);
}
function normalizeDurationSeconds(
rawDuration: number | null,
fallbackDuration: number | null,
maxDuration: number,
): number {
const safeFallback = fallbackDuration != null && fallbackDuration > 0 ? fallbackDuration : 0;
const safeRaw =
rawDuration != null && Number.isFinite(rawDuration) && rawDuration > 0 ? rawDuration : 0;
if (safeRaw > 0) return Math.min(safeRaw, maxDuration);
if (safeFallback > 0) return Math.min(safeFallback, maxDuration);
return 0;
}
function shouldIncludeTimelineNode(tag: ParsedTag, rootCompositionId: string | null): boolean {
const attrs = tag.attrs;
if (
attrs["data-composition-id"] &&
rootCompositionId &&
attrs["data-composition-id"] === rootCompositionId
) {
return false;
}
if (
tag.tagName === "script" ||
tag.tagName === "style" ||
tag.tagName === "link" ||
tag.tagName === "meta"
) {
return false;
}
if ((attrs.class || "").split(/\s+/).includes("__preview_render_frame__")) return false;
if (attrs["data-start"] != null) return true;
if (attrs["data-track-index"] != null) return true;
if (attrs["data-composition-src"] != null) return true;
if (attrs["data-composition-id"] != null) return true;
return tag.tagName === "video" || tag.tagName === "audio" || tag.tagName === "img";
}
function inferClipDuration(tag: ParsedTag, start: number, maxDuration: number): number | null {
const attrs = tag.attrs;
const durationAttr = parseNum(attrs["data-duration"]);
if (durationAttr != null && durationAttr > 0)
return normalizeDurationSeconds(durationAttr, null, maxDuration);
const endAttr = parseNum(attrs["data-end"]);
if (endAttr != null && endAttr > start) {
return normalizeDurationSeconds(endAttr - start, null, maxDuration);
}
if (tag.tagName === "video" || tag.tagName === "audio") {
const sourceDuration = parseNum(attrs["data-source-duration"]);
const playbackStart =
parseNum(attrs["data-playback-start"]) ?? parseNum(attrs["data-playbackStart"]) ?? 0;
if (sourceDuration != null && sourceDuration > 0) {
return normalizeDurationSeconds(
Math.max(0, sourceDuration - playbackStart),
null,
maxDuration,
);
}
}
if (attrs["data-composition-id"]) {
const sourceDuration = parseNum(attrs["data-source-duration"]);
if (sourceDuration != null && sourceDuration > 0) {
return normalizeDurationSeconds(sourceDuration, null, maxDuration);
}
}
return null;
}
function resolveNodeAssetUrl(tag: ParsedTag): string | null {
const attrs = tag.attrs;
return (
attrs.src ??
attrs["data-src"] ??
attrs["data-asset-src"] ??
attrs["data-video-src"] ??
attrs["data-image-src"] ??
null
);
}
function resolveRootTag(tags: ParsedTag[]): ParsedTag | null {
const explicitRoot = tags.find((tag) => tag.attrs["data-composition-id"] === "master");
if (explicitRoot) return explicitRoot;
return tags.find((tag) => tag.attrs["data-composition-id"] != null) ?? null;
}
function main() {
const { inputPath, forcedFps, forcedMaxDuration } = parseArgs();
if (!inputPath) {
console.error("Usage: bun run debug:timeline <path-to-html> [--fps 30] [--max-duration 1800]");
process.exit(2);
}
const resolvedPath = path.resolve(process.cwd(), inputPath);
if (!fs.existsSync(resolvedPath)) {
console.error(`File not found: ${resolvedPath}`);
process.exit(2);
}
const html = fs.readFileSync(resolvedPath, "utf-8");
const tags = parseTags(html);
const root = resolveRootTag(tags);
const rootCompositionId = root?.attrs["data-composition-id"] ?? null;
const maxDuration =
(forcedMaxDuration != null && forcedMaxDuration > 0
? forcedMaxDuration
: extractWindowNumber(html, "window.__HF_MAX_DURATION_SEC")) ?? 1800;
const canonicalFps =
(forcedFps != null && forcedFps > 0
? forcedFps
: (parseNum(root?.attrs["data-fps"]) ?? extractWindowNumber(html, "window.__HF_FPS"))) ?? 30;
const rootDurationRaw =
parseNum(root?.attrs["data-composition-duration"]) ??
parseNum(root?.attrs["data-duration"]) ??
parseNum(
tags.find((tag) => tag.tagName === "html")?.attrs["data-composition-duration"] ?? null,
);
const rootDuration = normalizeDurationSeconds(rootDurationRaw, null, maxDuration);
const nodes = tags.filter((tag) => shouldIncludeTimelineNode(tag, rootCompositionId));
const clips: TimelineClip[] = [];
let maxEnd = 0;
let unresolvedClipCount = 0;
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i];
const attrs = node.attrs;
const start = Math.max(0, parseNum(attrs["data-start"]) ?? 0);
const inferredDuration = inferClipDuration(node, start, maxDuration);
const hasDeterministicDuration = inferredDuration != null && inferredDuration > 0;
let duration = hasDeterministicDuration
? normalizeDurationSeconds(inferredDuration, 0, maxDuration)
: 0;
let durationSource: "deterministic" | "fallback" = "deterministic";
if (duration <= 0 && rootDuration > start) {
duration = normalizeDurationSeconds(rootDuration - start, 0, maxDuration);
durationSource = "fallback";
}
if (duration <= 0) {
unresolvedClipCount += 1;
continue;
}
if (hasDeterministicDuration) {
const end = start + duration;
if (end > maxEnd) maxEnd = end;
}
const kind =
node.tagName === "video"
? "video"
: node.tagName === "audio"
? "audio"
: node.tagName === "img"
? "image"
: "element";
const trackRaw = parseNum(attrs["data-track-index"]);
const track = trackRaw != null && Number.isFinite(trackRaw) ? Math.floor(trackRaw) : i;
clips.push({
id: attrs.id ?? null,
label: attrs["data-label"] ?? attrs["aria-label"] ?? attrs.id ?? kind,
start,
duration,
track,
kind,
tagName: node.tagName || null,
compositionId: attrs["data-composition-id"] ?? null,
compositionSrc: attrs["data-composition-src"] ?? null,
assetUrl: resolveNodeAssetUrl(node),
durationSource,
});
}
let effectiveDuration = 0;
if (maxEnd > 0) effectiveDuration = normalizeDurationSeconds(maxEnd, 0, maxDuration);
if (effectiveDuration <= 0)
effectiveDuration = normalizeDurationSeconds(rootDuration, 1, maxDuration);
if (effectiveDuration <= 0) effectiveDuration = 1;
const compositionWidth = parseNum(root?.attrs["data-width"]) ?? 1920;
const compositionHeight = parseNum(root?.attrs["data-height"]) ?? 1080;
const payload: TimelinePayload = {
source: "hf-preview",
type: "timeline",
durationInFrames: Math.max(1, Math.round(effectiveDuration * canonicalFps)),
clips,
scenes: [],
compositionWidth,
compositionHeight,
};
const output = {
file: resolvedPath,
debug: {
canonicalFps,
maxDurationSeconds: maxDuration,
rootCompositionId,
rootDurationSeconds: rootDuration,
deterministicMaxEndSeconds: maxEnd,
effectiveDurationSeconds: effectiveDuration,
unresolvedClipCount,
clipCount: clips.length,
},
payload,
};
console.log(JSON.stringify(output, null, 2));
}
main();
@@ -0,0 +1,104 @@
import fs from "node:fs";
import path from "node:path";
type GuardSpec = {
id: string;
description: string;
filePath: string;
pattern: RegExp;
};
type GuardCheckResult = {
passed: GuardSpec[];
failed: GuardSpec[];
};
// Keep only temporary source-shape guards that do not yet have behavioral
// coverage. Timeline replacement and early-play rebinding are exercised by
// init.test.ts and timelineRebindPolicy.test.ts instead of regexes.
const GUARD_SPECS: GuardSpec[] = [
{
id: "external_compositions_gate",
description: "Do not bind timelines before external compositions are loaded",
filePath: "src/runtime/init.ts",
pattern: /if\s*\(\s*!externalCompositionsReady\s*\)\s*return\s+false;/,
},
{
id: "child_timeline_activation",
description: "Force root child timelines active before composition binding",
filePath: "src/runtime/init.ts",
pattern: /timelineWithPaused\.paused\(false\)/,
},
{
id: "root_unusable_fallback",
description: "Fallback to composite timeline when root duration is unusable",
filePath: "src/runtime/init.ts",
pattern:
/if\s*\(\s*!isUsableTimelineDuration\(rootDurationSeconds\)\s*&&\s*rootChildCandidates\.length\s*>\s*0\s*\)/,
},
{
id: "external_script_ordering",
description: "Inject external composition scripts with deterministic ordering",
filePath: "src/runtime/compositionLoader.ts",
pattern: /injectedScript\.async\s*=\s*false;/,
},
{
id: "external_script_load_wait",
description: "Await external composition script load before continuing",
filePath: "src/runtime/compositionLoader.ts",
pattern: /await\s+waitForExternalScriptLoad\(injectedScript\);/,
},
];
function resolveFilePath(relativePath: string): string {
return path.resolve(process.cwd(), relativePath);
}
function checkGuards(guards: GuardSpec[]): GuardCheckResult {
const passed: GuardSpec[] = [];
const failed: GuardSpec[] = [];
for (const guard of guards) {
const absolutePath = resolveFilePath(guard.filePath);
if (!fs.existsSync(absolutePath)) {
failed.push(guard);
continue;
}
const content = fs.readFileSync(absolutePath, "utf8");
if (guard.pattern.test(content)) {
passed.push(guard);
} else {
failed.push(guard);
}
}
return { passed, failed };
}
function main(): void {
const result = checkGuards(GUARD_SPECS);
if (result.failed.length === 0) {
console.log(
JSON.stringify({
event: "runtime_preview_guards_lint_passed",
checkedGuards: GUARD_SPECS.length,
}),
);
return;
}
console.error(
JSON.stringify({
event: "runtime_preview_guards_lint_failed",
checkedGuards: GUARD_SPECS.length,
missingGuards: result.failed.map((guard) => ({
id: guard.id,
filePath: guard.filePath,
description: guard.description,
})),
}),
);
process.exit(1);
}
main();
@@ -0,0 +1,77 @@
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { dirname, extname, join, normalize, resolve } from "node:path";
const distDir = resolve(import.meta.dirname, "../dist");
const runtimeExtensions = new Set([".js", ".mjs", ".cjs", ".json", ".wasm", ".node"]);
function listJavaScriptFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir)) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
files.push(...listJavaScriptFiles(fullPath));
} else if (entry.endsWith(".js")) {
files.push(fullPath);
}
}
return files;
}
function hasRuntimeExtension(specifier: string): boolean {
return runtimeExtensions.has(extname(specifier));
}
function isRelativeSpecifier(specifier: string): boolean {
return specifier.startsWith("./") || specifier.startsWith("../");
}
function resolveExistingJsSpecifier(fromFile: string, specifier: string): string | undefined {
const absolute = resolve(dirname(fromFile), specifier);
const jsFile = `${absolute}.js`;
const indexFile = join(absolute, "index.js");
if (existsSync(jsFile)) return `${specifier}.js`;
if (existsSync(indexFile)) return `${specifier}/index.js`;
}
function resolveRuntimeSpecifier(fromFile: string, specifier: string): string {
if (!isRelativeSpecifier(specifier)) return specifier;
if (hasRuntimeExtension(specifier)) return specifier;
return resolveExistingJsSpecifier(fromFile, specifier) ?? specifier;
}
function rewriteSpecifiers(filePath: string, source: string): string {
const patterns = [
/(from\s+["'])(\.\.?\/[^"']+)(["'])/g,
/(import\s+["'])(\.\.?\/[^"']+)(["'])/g,
/(import\(\s*["'])(\.\.?\/[^"']+)(["']\s*\))/g,
];
return patterns.reduce(
(nextSource, pattern) =>
nextSource.replace(pattern, (_match, prefix: string, specifier: string, suffix: string) => {
return `${prefix}${resolveRuntimeSpecifier(filePath, specifier)}${suffix}`;
}),
source,
);
}
let changed = 0;
for (const filePath of listJavaScriptFiles(distDir)) {
const source = readFileSync(filePath, "utf8");
const rewritten = rewriteSpecifiers(filePath, source);
if (rewritten !== source) {
writeFileSync(filePath, rewritten);
changed += 1;
}
}
console.log(
JSON.stringify({
event: "core_esm_extensions_rewritten",
distDir: normalize(distDir),
changedFiles: changed,
}),
);
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { lintHyperframeHtml } from "@hyperframes/lint";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const VALID_COMPOSITION = `
<html>
<body>
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080" data-start="0">
<div id="stage"></div>
</div>
<script src="https://cdn.gsap.com/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["comp-1"] = tl;
</script>
</body>
</html>`;
async function testCleanFixturePasses() {
const result = await lintHyperframeHtml(VALID_COMPOSITION, { filePath: "valid.html" });
assert.equal(result.ok, true, "valid composition should pass without lint errors");
assert.equal(result.errorCount, 0, "valid composition should have zero lint errors");
}
async function testDetectsMissingCompositionHostId() {
const html = `
<html>
<body>
<div id="root" data-width="1080" data-height="1920">
<div data-composition-src="compositions/overlays.html"></div>
</div>
<script>
window.__timelines = {};
const tl = gsap.timeline({ paused: true });
window.__timelines["root"] = tl;
</script>
</body>
</html>
`;
const result = await lintHyperframeHtml(html);
const codes = result.findings.map((finding) => finding.code);
assert.equal(result.ok, false, "missing composition ids should fail lint");
assert.ok(codes.includes("root_missing_composition_id"));
assert.ok(codes.includes("host_missing_composition_id"));
}
async function testDetectsOverlappingGsapTweens() {
const html = `
<html>
<body>
<div id="main" data-composition-id="main" data-width="1080" data-height="1920">
<div id="frame"></div>
</div>
<script>
window.__timelines = {};
const tl = gsap.timeline({ paused: true });
tl.to("#frame", { y: 15, duration: 2.5, repeat: 1, yoyo: true }, 1.5);
tl.to("#frame", { y: 400, duration: 1.2 }, 4.5);
window.__timelines["main"] = tl;
</script>
</body>
</html>
`;
const result = await lintHyperframeHtml(html);
const overlapFinding = result.findings.find(
(finding) => finding.code === "overlapping_gsap_tweens",
);
assert.ok(overlapFinding, "expected an overlapping GSAP tween warning");
assert.equal(overlapFinding?.severity, "warning");
}
function testCliJsonOutput() {
const tempDir = mkdtempSync(path.join(tmpdir(), "hf-core-lint-script-"));
try {
const fixturePath = path.join(tempDir, "index.html");
writeFileSync(fixturePath, VALID_COMPOSITION, "utf8");
const stdout = execFileSync("bun", ["run", "check:hyperframe-html", "--json", fixturePath], {
cwd: ROOT,
encoding: "utf8",
});
const payload = JSON.parse(stdout);
assert.equal(payload.ok, true);
assert.equal(typeof payload.errorCount, "number");
assert.ok(Array.isArray(payload.findings));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
async function main() {
await testCleanFixturePasses();
await testDetectsMissingCompositionHostId();
await testDetectsOverlappingGsapTweens();
testCliJsonOutput();
console.log("hyperframe linter tests passed");
}
await main();
@@ -0,0 +1,36 @@
import { buildHyperframesRuntimeScript } from "../src/inline-scripts/hyperframesRuntime.engine";
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(message);
}
}
const baseline = buildHyperframesRuntimeScript();
assert(baseline !== null, "buildHyperframesRuntimeScript() returned null — entry.ts not found");
const parityEnabled = buildHyperframesRuntimeScript({ defaultParityMode: true });
assert(parityEnabled !== null, "Parity-enabled build returned null");
const parityDisabled = buildHyperframesRuntimeScript({ defaultParityMode: false });
assert(parityDisabled !== null, "Parity-disabled build returned null");
const withSourceUrl = buildHyperframesRuntimeScript({
sourceUrl: "hyperframe.runtime.iife.js",
});
assert(withSourceUrl !== null, "Build with sourceUrl returned null");
assert(baseline.includes("window.__player"), "Baseline runtime should include player contract");
assert(parityEnabled.length > 0, "Parity-enabled build should produce non-empty runtime source");
assert(parityDisabled.length > 0, "Parity-disabled build should produce non-empty runtime source");
assert(
withSourceUrl.includes("//# sourceURL=hyperframe.runtime.iife.js"),
"Build with sourceUrl should append sourceURL comment",
);
console.log(
JSON.stringify({
event: "hyperframe_runtime_behavior_verified",
baselineBytes: baseline.length,
parityEnabledBytes: parityEnabled.length,
parityDisabledBytes: parityDisabled.length,
sourceUrlBytes: withSourceUrl.length,
}),
);
@@ -0,0 +1,46 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadHyperframeRuntimeSource } from "../src/inline-scripts/hyperframe";
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(message);
}
}
const runtimeSource = loadHyperframeRuntimeSource();
assert(runtimeSource !== null, "loadHyperframeRuntimeSource() returned null — entry.ts not found");
const requiredSnippets = [
"window.__player",
"window.__playerReady",
"window.__renderReady",
"hf-preview",
"hf-parent",
"renderSeek",
"__hyperframes",
"fitTextFontSize",
];
for (const snippet of requiredSnippets) {
assert(runtimeSource.includes(snippet), `Runtime contract snippet missing: ${snippet}`);
}
const scriptDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
const manifestPath = resolve(scriptDir, "../dist/hyperframe.manifest.json");
try {
const manifestRaw = readFileSync(manifestPath, "utf8");
const manifest = JSON.parse(manifestRaw) as { artifacts?: { iife?: string; esm?: string } };
assert(Boolean(manifest.artifacts?.iife), "Manifest is missing iife artifact");
assert(Boolean(manifest.artifacts?.esm), "Manifest is missing esm artifact");
} catch {
// Build may not have run yet; contract-only checks above still provide signal.
}
console.log(
JSON.stringify({
event: "hyperframe_runtime_contract_verified",
requiredSnippetsChecked: requiredSnippets.length,
}),
);
@@ -0,0 +1,48 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(message);
}
}
const scriptDir = dirname(fileURLToPath(import.meta.url));
const initPath = resolve(scriptDir, "../src/runtime/init.ts");
const timelinePath = resolve(scriptDir, "../src/runtime/timeline.ts");
const initSource = readFileSync(initPath, "utf8");
const timelineSource = readFileSync(timelinePath, "utf8");
// Guard against regressions where preview duration gets capped by earliest video.
assert(
!initSource.includes("resolveMainVideoDurationSeconds"),
"init.ts should not use first-video duration helper",
);
assert(
!initSource.includes("Math.max(0, Math.min(safeDuration, mediaFloor))"),
"init.ts should not hard-clamp safe duration to media floor",
);
assert(
initSource.includes("resolveMediaWindowDurationSeconds"),
"init.ts should compute media window duration across timed media",
);
// Timeline payload windowing should also avoid first-video truncation.
assert(
!timelineSource.includes("resolveMainVideoWindowEndSeconds"),
"timeline.ts should not use first-video window end helper",
);
assert(
timelineSource.includes("resolveMediaWindowEndSeconds"),
"timeline.ts should compute media window end across timed media",
);
console.log(
JSON.stringify({
event: "hyperframe_runtime_duration_guards_verified",
initPath,
timelinePath,
}),
);
@@ -0,0 +1,44 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { HYPERFRAME_RUNTIME_ARTIFACTS } from "../src/inline-scripts/hyperframe";
function assert(condition: unknown, message: string): void {
if (!condition) throw new Error(message);
}
const scriptsDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
const distDir = resolve(scriptsDir, "../dist");
const iifePath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.iife);
const manifestPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.manifest);
const iifeSource = readFileSync(iifePath, "utf8");
const manifestRaw = readFileSync(manifestPath, "utf8");
const manifest = JSON.parse(manifestRaw) as {
sha256?: string;
artifacts?: { iife?: string; esm?: string };
};
assert(iifeSource.length > 0, "IIFE runtime artifact is empty");
assert(
manifest.artifacts?.iife === HYPERFRAME_RUNTIME_ARTIFACTS.iife,
"Manifest iife artifact name is not strict expected value",
);
assert(
manifest.artifacts?.esm === HYPERFRAME_RUNTIME_ARTIFACTS.esm,
"Manifest esm artifact name is not strict expected value",
);
assert(Boolean(manifest.artifacts?.iife), "Manifest missing iife artifact entry");
assert(Boolean(manifest.artifacts?.esm), "Manifest missing esm artifact entry");
const runtimeSha = createHash("sha256").update(iifeSource, "utf8").digest("hex");
assert(runtimeSha === manifest.sha256, "Manifest sha256 does not match runtime artifact");
console.log(
JSON.stringify({
event: "hyperframe_runtime_parity_verified",
bytes: iifeSource.length,
sha256: runtimeSha,
}),
);
@@ -0,0 +1,102 @@
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(message);
}
}
const ALLOWED_HOSTS = new Set(["runtime.example.com"]);
const ALLOWED_PATH_PREFIX = "/static/hyperframes-runtime/";
function isAllowedRuntimeUrl(candidate: string): boolean {
try {
const parsed = new URL(candidate);
return (
parsed.protocol === "https:" &&
ALLOWED_HOSTS.has(parsed.hostname.toLowerCase()) &&
parsed.pathname.startsWith(ALLOWED_PATH_PREFIX) &&
parsed.pathname.endsWith(".js")
);
} catch {
return false;
}
}
function isGuardedPreviewMessage(data: unknown): boolean {
if (!data || typeof data !== "object") {
return false;
}
const record = data as { source?: unknown; type?: unknown };
const source = typeof record.source === "string" ? record.source : null;
const type = typeof record.type === "string" ? record.type : null;
const guardedTypes = new Set([
"state",
"timeline",
"element-picked",
"element-picked-many",
"element-pick-candidates",
"pick-mode-cancelled",
]);
if (!type || !guardedTypes.has(type)) {
return false;
}
return source === "hf-preview";
}
const allowedRuntimeFixtures = [
"https://runtime.example.com/static/hyperframes-runtime/hyperframe.runtime.iife.js",
"https://runtime.example.com/static/hyperframes-runtime/v2026.02.20/hyperframe.runtime.iife.js",
];
const blockedRuntimeFixtures = [
"http://runtime.example.com/static/hyperframes-runtime/hyperframe.runtime.iife.js",
"javascript:alert(1)",
"data:text/javascript,alert(1)",
"https://evil.example/static/hyperframes-runtime/hyperframe.runtime.iife.js",
"https://runtime.example.com/static/other/hyperframe.runtime.iife.js",
"https://runtime.example.com/static/hyperframes-runtime/hyperframe.runtime.iife.css",
];
for (const fixture of allowedRuntimeFixtures) {
assert(isAllowedRuntimeUrl(fixture), `Expected runtime URL to be allowed: ${fixture}`);
}
for (const fixture of blockedRuntimeFixtures) {
assert(!isAllowedRuntimeUrl(fixture), `Expected runtime URL to be blocked: ${fixture}`);
}
const allowedMessages = [
{ type: "state", source: "hf-preview" },
{ type: "timeline", source: "hf-preview" },
{ type: "element-picked", source: "hf-preview" },
];
const blockedMessages = [
{ type: "state", source: "hf-parent" },
{ type: "timeline", source: "evil-origin" },
{ type: "element-picked", source: "hf-parent" },
{ type: "pick-mode-cancelled", source: null },
{ type: "unknown-type", source: "hf-preview" },
];
for (const fixture of allowedMessages) {
assert(
isGuardedPreviewMessage(fixture),
`Expected message fixture to pass guard: ${JSON.stringify(fixture)}`,
);
}
for (const fixture of blockedMessages) {
assert(
!isGuardedPreviewMessage(fixture),
`Expected message fixture to fail guard: ${JSON.stringify(fixture)}`,
);
}
console.log(
JSON.stringify({
event: "hyperframe_runtime_security_fixtures_verified",
allowedRuntimeFixtures: allowedRuntimeFixtures.length,
blockedRuntimeFixtures: blockedRuntimeFixtures.length,
allowedMessages: allowedMessages.length,
blockedMessages: blockedMessages.length,
}),
);
@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import { createGsapAdapter } from "../src/runtime/adapters/gsap";
import { createRuntimePlayer } from "../src/runtime/player";
import type { RuntimeTimelineLike } from "../src/runtime/types";
type Call = {
method: "pause" | "seek" | "totalTime";
time?: number;
suppressEvents?: boolean;
};
function createTimeline(withTotalTime: boolean): { calls: Call[]; timeline: RuntimeTimelineLike } {
const calls: Call[] = [];
const timeline: RuntimeTimelineLike = {
play: () => undefined,
pause: () => {
calls.push({ method: "pause" });
},
seek: (timeSeconds: number, suppressEvents?: boolean) => {
calls.push({ method: "seek", time: timeSeconds, suppressEvents });
},
time: () => 0,
duration: () => 12,
add: () => undefined,
paused: () => undefined,
set: () => undefined,
};
if (withTotalTime) {
timeline.totalTime = (timeSeconds: number, suppressEvents?: boolean) => {
calls.push({ method: "totalTime", time: timeSeconds, suppressEvents });
};
}
return { calls, timeline };
}
function createPlayer(timeline: RuntimeTimelineLike) {
const deterministicSeekCalls: number[] = [];
const syncMediaCalls: number[] = [];
const renderFrameSeekCalls: number[] = [];
const player = createRuntimePlayer({
getTimeline: () => timeline,
setTimeline: () => undefined,
getIsPlaying: () => false,
setIsPlaying: () => undefined,
getPlaybackRate: () => 1,
setPlaybackRate: () => undefined,
getCanonicalFps: () => 30,
onSyncMedia: (timeSeconds) => {
syncMediaCalls.push(timeSeconds);
},
onStatePost: () => undefined,
onDeterministicSeek: (timeSeconds) => {
deterministicSeekCalls.push(timeSeconds);
},
onDeterministicPause: () => undefined,
onDeterministicPlay: () => undefined,
onRenderFrameSeek: (timeSeconds) => {
renderFrameSeekCalls.push(timeSeconds);
},
onShowNativeVideos: () => undefined,
getSafeDuration: () => 12,
});
return { player, deterministicSeekCalls, syncMediaCalls, renderFrameSeekCalls };
}
function testSeekUsesDeterministicGsapPath(): void {
const { calls, timeline } = createTimeline(true);
const { player, deterministicSeekCalls, syncMediaCalls, renderFrameSeekCalls } =
createPlayer(timeline);
const quantizedTime = 2;
player.seek(2.017);
assert.deepEqual(
calls,
[{ method: "pause" }, { method: "totalTime", time: quantizedTime, suppressEvents: false }],
"player.seek() should use quantized totalTime() when available",
);
assert.deepEqual(
deterministicSeekCalls,
[quantizedTime],
"player.seek() should notify adapters with the quantized time",
);
assert.deepEqual(syncMediaCalls, [quantizedTime], "media sync should use quantized time");
assert.deepEqual(
renderFrameSeekCalls,
[quantizedTime],
"render frame seek should use quantized time",
);
}
function testGsapAdapterPreservesTotalTime(): void {
const { calls, timeline } = createTimeline(true);
const adapter = createGsapAdapter({ getTimeline: () => timeline });
const seekTime = 2.033333333333333;
adapter.seek({ time: seekTime });
assert.deepEqual(
calls,
[
{ method: "pause" },
// Nudge to force GSAP 3.x dirty state before the real seek
{ method: "totalTime", time: seekTime + 0.001, suppressEvents: true },
{ method: "totalTime", time: seekTime, suppressEvents: false },
],
"GSAP adapter should nudge then seek via totalTime() (not downgrade to seek())",
);
}
function testGsapAdapterFallsBackToSeek(): void {
const { calls, timeline } = createTimeline(false);
const adapter = createGsapAdapter({ getTimeline: () => timeline });
adapter.seek({ time: 1.5 });
assert.deepEqual(
calls,
[{ method: "pause" }, { method: "seek", time: 1.5, suppressEvents: false }],
"GSAP adapter should keep working with timelines that only expose seek()",
);
}
testSeekUsesDeterministicGsapPath();
testGsapAdapterPreservesTotalTime();
testGsapAdapterFallsBackToSeek();
console.log(
JSON.stringify({
event: "hyperframe_runtime_seek_verified",
assertions: 3,
}),
);
+136
View File
@@ -0,0 +1,136 @@
import { describe, it, expect, vi } from "vitest";
import { createGSAPFrameAdapter } from "./gsap.js";
import type { FrameAdapter, FrameAdapterContext } from "./types.js";
function makeMockTimeline(duration = 10, totalDuration?: number) {
return {
duration: vi.fn().mockReturnValue(duration),
totalDuration: totalDuration !== undefined ? vi.fn().mockReturnValue(totalDuration) : undefined,
seek: vi.fn(),
pause: vi.fn(),
};
}
describe("createGSAPFrameAdapter", () => {
it("returns a FrameAdapter with required properties", () => {
const timeline = makeMockTimeline(10);
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
expect(adapter.id).toBe("gsap");
expect(typeof adapter.init).toBe("function");
expect(typeof adapter.getDurationFrames).toBe("function");
expect(typeof adapter.seekFrame).toBe("function");
});
it("uses custom id when provided", () => {
const timeline = makeMockTimeline(10);
const adapter = createGSAPFrameAdapter({ id: "custom-gsap", fps: 30, timeline });
expect(adapter.id).toBe("custom-gsap");
});
it("getDurationFrames returns correct frame count", () => {
const timeline = makeMockTimeline(10); // 10 seconds
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
// 10 seconds * 30 fps = 300 frames
expect(adapter.getDurationFrames()).toBe(300);
});
it("getDurationFrames uses totalDuration when available", () => {
const timeline = makeMockTimeline(5, 15); // duration=5, totalDuration=15
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
// Should use totalDuration (15) not duration (5)
// 15 seconds * 30 fps = 450 frames
expect(adapter.getDurationFrames()).toBe(450);
});
it("getDurationFrames returns 0 for zero-duration timeline", () => {
const timeline = makeMockTimeline(0);
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
expect(adapter.getDurationFrames()).toBe(0);
});
it("init pauses the timeline", () => {
const timeline = makeMockTimeline(10);
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
adapter.init!({} as FrameAdapterContext);
expect(timeline.pause).toHaveBeenCalled();
});
it("seekFrame seeks to the correct time in seconds", () => {
const timeline = makeMockTimeline(10);
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
adapter.seekFrame(90); // Frame 90 at 30fps = 3 seconds
expect(timeline.seek).toHaveBeenCalledWith(3, false);
expect(timeline.pause).toHaveBeenCalled();
});
it("seekFrame clamps negative frames to 0", () => {
const timeline = makeMockTimeline(10);
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
adapter.seekFrame(-5);
expect(timeline.seek).toHaveBeenCalledWith(0, false);
});
it("seekFrame handles non-finite frame numbers", () => {
const timeline = makeMockTimeline(10);
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
adapter.seekFrame(NaN);
expect(timeline.seek).toHaveBeenCalledWith(0, false);
});
it("seekFrame handles Infinity", () => {
const timeline = makeMockTimeline(10);
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
adapter.seekFrame(Infinity);
// Infinity is not finite, so it gets clamped to 0
expect(timeline.seek).toHaveBeenCalledWith(0, false);
});
it("works without pause method on timeline", () => {
const timeline = {
duration: vi.fn().mockReturnValue(5),
seek: vi.fn(),
// No pause method
};
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
// Should not throw
adapter.init!({} as FrameAdapterContext);
adapter.seekFrame(0);
expect(timeline.seek).toHaveBeenCalled();
});
it("getDurationFrames ceiling the frame count", () => {
const timeline = makeMockTimeline(1.05); // 1.05 seconds * 30 fps = 31.5 -> ceil = 32
const adapter = createGSAPFrameAdapter({ fps: 30, timeline });
expect(adapter.getDurationFrames()).toBe(32);
});
});
describe("FrameAdapter type", () => {
it("adapter conforms to FrameAdapter interface", () => {
const timeline = makeMockTimeline(10);
const adapter: FrameAdapter = createGSAPFrameAdapter({ fps: 30, timeline });
expect(adapter.id).toBeDefined();
expect(adapter.getDurationFrames).toBeDefined();
expect(adapter.seekFrame).toBeDefined();
});
});
+44
View File
@@ -0,0 +1,44 @@
import type { FrameAdapter } from "./types";
export interface GSAPTimelineLike {
// Base timeline span excluding repeats.
duration: () => number;
// Full span including repeats/yoyo when available.
totalDuration?: () => number;
seek: (timeInSeconds: number, suppressEvents?: boolean) => unknown;
pause?: () => unknown;
}
export interface CreateGSAPFrameAdapterOptions {
id?: string;
fps: number;
timeline: GSAPTimelineLike;
}
export function createGSAPFrameAdapter(options: CreateGSAPFrameAdapterOptions): FrameAdapter {
const { fps, timeline } = options;
const adapterId = options.id ?? "gsap";
const getDurationSeconds = (): number => {
const totalDuration =
typeof timeline.totalDuration === "function" ? timeline.totalDuration() : timeline.duration();
return Number.isFinite(totalDuration) && totalDuration > 0 ? totalDuration : 0;
};
return {
id: adapterId,
init: () => {
timeline.pause?.();
},
getDurationFrames: () => {
const durationSeconds = getDurationSeconds();
return Math.max(0, Math.ceil(durationSeconds * fps));
},
seekFrame: (frame: number) => {
const clampedFrame = Number.isFinite(frame) ? Math.max(0, frame) : 0;
const targetSeconds = clampedFrame / fps;
timeline.pause?.();
timeline.seek(targetSeconds, false);
},
};
}
+3
View File
@@ -0,0 +1,3 @@
export type { FrameAdapter, FrameAdapterContext } from "./types";
export type { GSAPTimelineLike, CreateGSAPFrameAdapterOptions } from "./gsap";
export { createGSAPFrameAdapter } from "./gsap";
+15
View File
@@ -0,0 +1,15 @@
export interface FrameAdapterContext {
compositionId: string;
fps: number;
width: number;
height: number;
rootElement?: HTMLElement;
}
export interface FrameAdapter {
id: string;
init?: (ctx: FrameAdapterContext) => Promise<void> | void;
getDurationFrames: () => number;
seekFrame: (frame: number) => Promise<void> | void;
destroy?: () => Promise<void> | void;
}
+285
View File
@@ -0,0 +1,285 @@
// bpm-detective touches `window` at module top-level, so it must NOT be a static
// import (that would crash any non-browser import of this module graph, e.g.
// vitest/SSR). It's loaded lazily inside analyzeMusicFromBuffer, which only runs
// in the browser.
type BpmDetect = (buffer: AudioBuffer) => number;
let bpmDetectivePromise: Promise<BpmDetect | null> | null = null;
function loadBpmDetective(): Promise<BpmDetect | null> {
if (!bpmDetectivePromise) {
bpmDetectivePromise = import(
// @ts-ignore -- no type declarations for bpm-detective
"bpm-detective"
)
.then((m) => ((m as { default?: BpmDetect }).default ?? (m as unknown as BpmDetect)) || null)
.catch(() => null);
}
return bpmDetectivePromise;
}
const WINDOW_SIZE = 1024;
const HOP_SIZE = 512;
export interface MusicBeatAnalysis {
beatTimes: number[];
/** Per-beat loudness 01 (local RMS / peak), aligned by index with beatTimes. */
beatStrengths: number[];
bpm: number | null;
bpmConfidence: "high" | "low" | "uncertain";
/** Decoded mono samples — retained so strength can be measured at user-added
* beats. Audio-file coordinates. May be null if decode data was dropped. */
channelData: Float32Array | null;
sampleRate: number;
/** Reference peak RMS used to normalize beat strengths. */
peak: number;
}
const STRENGTH_WINDOW_S = 0.05; // ±50ms RMS window
/** Local RMS amplitude at a given audio-file time. */
export function computeRmsAt(channelData: Float32Array, sampleRate: number, time: number): number {
const halfWindow = Math.floor(sampleRate * STRENGTH_WINDOW_S);
const center = Math.floor(time * sampleRate);
const start = Math.max(0, center - halfWindow);
const end = Math.min(channelData.length, center + halfWindow);
let sum = 0;
for (let i = start; i < end; i++) {
const s = channelData[i] ?? 0;
sum += s * s;
}
return Math.sqrt(sum / Math.max(end - start, 1));
}
/** Normalized beat strength (01) at an audio-file time, using a track peak. */
export function strengthAtTime(
analysis: Pick<MusicBeatAnalysis, "channelData" | "sampleRate" | "peak">,
time: number,
): number {
if (!analysis.channelData || analysis.peak <= 0) return 0.5;
return Math.min(1, computeRmsAt(analysis.channelData, analysis.sampleRate, time) / analysis.peak);
}
// fallow-ignore-next-line complexity
export async function detectBeats(audioBuffer: AudioBuffer): Promise<number[]> {
const channelData = audioBuffer.getChannelData(0);
const sampleRate = audioBuffer.sampleRate;
const energies: number[] = [];
for (let i = 0; i < channelData.length - WINDOW_SIZE; i += HOP_SIZE) {
let sum = 0;
for (let j = 0; j < WINDOW_SIZE; j++) {
const sample = channelData[i + j]!;
sum += sample * sample;
}
energies.push(sum / WINDOW_SIZE);
}
const beats: number[] = [];
const localWindowSize = 20;
for (let i = localWindowSize; i < energies.length - localWindowSize; i++) {
let localMean = 0;
for (let j = i - localWindowSize; j < i + localWindowSize; j++) {
localMean += energies[j]!;
}
localMean /= localWindowSize * 2;
const threshold = localMean * 1.5;
const current = energies[i]!;
if (
current > threshold &&
current > (energies[i - 1] ?? 0) &&
current > (energies[i + 1] ?? 0)
) {
const timeInSeconds = (i * HOP_SIZE) / sampleRate;
if (beats.length === 0 || timeInSeconds - beats[beats.length - 1]! > 0.1) {
beats.push(Math.round(timeInSeconds * 1000) / 1000);
}
}
}
return beats;
}
function computeBpmFromBeats(beatTimes: number[]): number | null {
if (beatTimes.length < 4) return null;
const iois: number[] = [];
for (let i = 1; i < beatTimes.length; i++) {
iois.push(beatTimes[i]! - beatTimes[i - 1]!);
}
const sorted = [...iois].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const medianIoi = sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!;
if (medianIoi <= 0) return null;
return Math.round((60 / medianIoi) * 10) / 10;
}
// Fold into 60120 for octave-safe BPM comparison
function canonicalizeBpm(bpm: number): number {
let b = bpm;
while (b > 120) b /= 2;
while (b < 60) b *= 2;
return b;
}
// Pick the *2/÷2 octave of `bpm` whose beat interval is closest to the onset
// pulse, so a half-time detective reading (e.g. 174 for an 87bpm song) doesn't
// produce a double-density grid.
function octaveAlignBpm(bpm: number, reference: number): number {
const candidates = [bpm / 2, bpm, bpm * 2];
let best = bpm;
let bestDist = Number.POSITIVE_INFINITY;
for (const c of candidates) {
if (c <= 0) continue;
const dist = Math.abs(c - reference);
if (dist < bestDist) {
bestDist = dist;
best = c;
}
}
return best;
}
// fallow-ignore-next-line complexity
function regularizeBeats(rawBeats: number[], bpm: number, duration: number): number[] {
if (rawBeats.length === 0 || bpm <= 0 || duration <= 0) return rawBeats;
const beatInterval = 60 / bpm;
// Guard against a pathological (octave-misread) tempo producing a millisecond
// interval → tens of thousands of grid beats that freeze the timeline. 480 BPM
// (0.125s) is well above any real music tempo; bail to the raw onsets instead.
if (beatInterval < 0.125) return rawBeats;
const threshold = beatInterval * 0.25;
// Find phase offset that maximally aligns with raw onsets
let bestOffset = 0;
let bestScore = -1;
for (const anchor of rawBeats.slice(0, 10)) {
const offset = ((anchor % beatInterval) + beatInterval) % beatInterval;
let score = 0;
for (const rb of rawBeats) {
const phase = ((rb % beatInterval) + beatInterval) % beatInterval;
const dist = Math.min(Math.abs(phase - offset), beatInterval - Math.abs(phase - offset));
if (dist < threshold) score++;
}
if (score > bestScore) {
bestScore = score;
bestOffset = offset;
}
}
const beats: number[] = [];
for (let t = bestOffset; t <= duration + 0.001; t += beatInterval) {
beats.push(Math.round(t * 1000) / 1000);
}
return beats;
}
// Drop beats that fall in silent/near-silent regions (e.g. intro/outro) and
// score each surviving beat's loudness (local RMS / peak) for brightness.
function gateBeatsBySilence(
beats: number[],
channelData: Float32Array,
sampleRate: number,
): { times: number[]; strengths: number[]; peak: number } {
if (beats.length === 0) return { times: beats, strengths: [], peak: 1e-6 };
const energies = beats.map((t) => computeRmsAt(channelData, sampleRate, t));
const peak = Math.max(...energies, 1e-6);
const threshold = peak * 0.12;
const times: number[] = [];
const strengths: number[] = [];
for (let i = 0; i < beats.length; i++) {
if (energies[i]! >= threshold) {
times.push(beats[i]!);
strengths.push(Math.min(1, energies[i]! / peak));
}
}
return { times, strengths, peak };
}
// fallow-ignore-next-line complexity
export async function analyzeMusicFromBuffer(audioBuffer: AudioBuffer): Promise<MusicBeatAnalysis> {
const channelData = audioBuffer.getChannelData(0);
const sampleRate = audioBuffer.sampleRate;
const duration = audioBuffer.duration;
const rawBeats = await detectBeats(audioBuffer);
const onsetBpm = computeBpmFromBeats(rawBeats);
let detectiveBpm: number | null = null;
try {
const detect = await loadBpmDetective();
if (detect) detectiveBpm = detect(audioBuffer);
} catch {
// Not enough peaks or browser context unavailable
}
let bpm: number | null = onsetBpm;
let confidence: MusicBeatAnalysis["bpmConfidence"] = "uncertain";
let regularizeBpm: number | null = null;
if (onsetBpm !== null && detectiveBpm !== null) {
const pctDiff =
Math.abs(canonicalizeBpm(onsetBpm) - canonicalizeBpm(detectiveBpm)) /
canonicalizeBpm(detectiveBpm);
if (pctDiff < 0.05) {
// Detective folds tempo into 90180; re-pick the octave nearest the onset
// pulse so a half-time track isn't gridded at double density.
bpm = octaveAlignBpm(detectiveBpm, onsetBpm);
confidence = "high";
regularizeBpm = bpm;
} else if (pctDiff < 0.1) {
bpm = Math.round((onsetBpm + detectiveBpm) / 2);
confidence = "low";
regularizeBpm = bpm;
} else {
bpm = onsetBpm;
confidence = "uncertain";
}
} else if (onsetBpm !== null) {
bpm = onsetBpm;
confidence = "low";
regularizeBpm = onsetBpm;
} else if (detectiveBpm !== null) {
bpm = detectiveBpm;
confidence = "low";
regularizeBpm = detectiveBpm;
}
const gridBeats =
regularizeBpm !== null ? regularizeBeats(rawBeats, regularizeBpm, duration) : rawBeats;
const gated = gateBeatsBySilence(gridBeats, channelData, sampleRate);
return {
beatTimes: gated.times,
beatStrengths: gated.strengths,
bpm,
bpmConfidence: confidence,
channelData,
sampleRate,
peak: gated.peak,
};
}
export async function detectBeatsFromUrl(url: string): Promise<number[]> {
const audioContext = new AudioContext();
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return detectBeats(audioBuffer);
} finally {
await audioContext.close();
}
}
export async function analyzeMusicFromUrl(url: string): Promise<MusicBeatAnalysis> {
const audioContext = new AudioContext();
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return analyzeMusicFromBuffer(audioBuffer);
} finally {
await audioContext.close();
}
}
+113
View File
@@ -0,0 +1,113 @@
// Persistence for beat data: one JSON file per audio file, matched by the
// audio's project-relative path. Lives under `beats/` in the project so it
// survives the audio being removed and re-added.
interface BeatFileData {
version: 1;
audio: string;
beats: { time: number; strength: number }[];
}
/** Project-relative path of the audio file behind a (possibly absolute) src URL. */
export function audioRelPathForSrc(src: string | null | undefined): string | null {
if (!src) return null;
// blob:/data: URLs have no stable identity across sessions — not persistable.
if (/^(blob:|data:)/i.test(src)) return null;
// Studio preview URLs: /api/projects/<id>/preview[/comp]/<relpath>.
// Parsed with indexOf/slice (not a regex) to avoid polynomial backtracking
// on adversarial inputs (CodeQL js/polynomial-redos).
let rel: string | null = null;
const PREVIEW = "/preview/";
const previewIdx = src.indexOf(PREVIEW);
if (previewIdx !== -1) {
let after = src.slice(previewIdx + PREVIEW.length);
// Strip query/hash (single char class — linear, ReDoS-safe).
const queryOrHash = after.search(/[?#]/);
if (queryOrHash !== -1) after = after.slice(0, queryOrHash);
if (after.startsWith("comp/")) after = after.slice("comp/".length);
rel = after ? decodeURIComponent(after) : null;
}
if (!rel) {
// Fall back to the FULL pathname (not just basename) so two files with the
// same name in different folders don't collide on one beat file.
try {
rel = decodeURIComponent(new URL(src, "http://_").pathname);
} catch {
rel = src;
}
}
if (!rel) return null;
rel = rel.replace(/^\/+/, "");
return rel || null;
}
/** Path of the beat file for a given audio src, or null if it can't be derived. */
export function beatFilePathForSrc(src: string | null | undefined): string | null {
const rel = audioRelPathForSrc(src);
return rel ? `beats/${rel}.json` : null;
}
export function serializeBeats(times: number[], strengths: number[], audio: string): string {
const beats = times.map((t, i) => ({
time: Math.round(t * 1000) / 1000,
strength: Math.round((strengths[i] ?? 0.5) * 1000) / 1000,
}));
const data: BeatFileData = { version: 1, audio, beats };
return `${JSON.stringify(data, null, 2)}\n`;
}
export function parseBeats(content: string): { times: number[]; strengths: number[] } | null {
try {
const data = JSON.parse(content) as BeatFileData;
// Gate on the schema version so a future v2 file (with changed semantics)
// isn't silently parsed as v1 — an unknown version is treated as absent.
if (!data || data.version !== 1 || !Array.isArray(data.beats)) return null;
const times: number[] = [];
const strengths: number[] = [];
for (const b of data.beats) {
if (b && typeof b.time === "number" && Number.isFinite(b.time)) {
times.push(b.time);
// Clamp to [0,1] — a hand-edited file could carry an out-of-range or
// non-finite strength, and the renderers feed it into Math.pow(s, 2.2)
// (NaN for a negative base).
const s = typeof b.strength === "number" && Number.isFinite(b.strength) ? b.strength : 0.5;
strengths.push(Math.max(0, Math.min(1, s)));
}
}
return { times, strengths };
} catch {
return null;
}
}
const MUSIC_ID_RE = /\b(music|bgm|soundtrack|background[-_]?music)\b/i;
function attr(tag: string, name: string): string | null {
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, "i"));
return m ? m[1]! : null;
}
/**
* Find the music track's src in composition HTML, applying the SAME rules as the
* Studio's `isMusicTrack` so the CLI and Studio agree on which `<audio>` is music:
* the FIRST `<audio>` (in document order) where data-timeline-role="music", or —
* when no role is set — whose id matches the music regex. An explicit non-music
* role excludes the element. Returns the raw src attribute, or null.
*/
export function findMusicAudioSrc(html: string): string | null {
// `[^>]*` spans newlines (it's a negated class, not `.`), so multi-line opening
// tags are handled. HyperFrames authors src as an attribute on <audio>.
const tags = html.match(/<audio\b[^>]*>/gi) ?? [];
for (const tag of tags) {
const src = attr(tag, "src");
if (!src) continue;
const role = attr(tag, "data-timeline-role");
if (role) {
if (role === "music") return src;
continue; // explicit non-music role excludes
}
const id = attr(tag, "id");
if (id && MUSIC_ID_RE.test(id)) return src;
}
return null;
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./beatDetection.js";
export * from "./beatFile.js";
+181
View File
@@ -0,0 +1,181 @@
import { describe, expect, it } from "vitest";
import {
HF_COLOR_GRADING_COLOR_SPACE,
HF_COLOR_GRADING_PRESETS,
isHfColorGradingActive,
normalizeHfColorGrading,
normalizeHfColorGradingWithVariables,
serializeHfColorGrading,
} from "./colorGrading";
describe("color grading", () => {
it("parses preset shorthand", () => {
const grading = normalizeHfColorGrading("warm-clean");
expect(grading?.preset).toBe("warm-clean");
expect(grading?.colorSpace).toBe(HF_COLOR_GRADING_COLOR_SPACE);
expect(grading?.adjust.temperature).toBeGreaterThan(0);
expect(isHfColorGradingActive(grading)).toBe(true);
});
it("includes consumer-friendly filter presets", () => {
expect(HF_COLOR_GRADING_PRESETS.some((preset) => preset.id === "fresh-pop")).toBe(true);
expect(normalizeHfColorGrading("mono-clean")?.adjust.saturation).toBe(-1);
expect(normalizeHfColorGrading("vintage-wash")?.details.vignette).toBeGreaterThan(0);
expect(normalizeHfColorGrading("food-pop")?.adjust.saturation).toBeGreaterThan(0);
expect(normalizeHfColorGrading("food-pop")?.adjust.vibrance).toBeGreaterThan(0);
});
it("merges manual adjustments over preset values", () => {
const grading = normalizeHfColorGrading({
preset: "warm-clean",
intensity: 0.5,
adjust: { temperature: -0.25, contrast: 0.2 },
});
expect(grading?.intensity).toBe(0.5);
expect(grading?.adjust.temperature).toBe(-0.25);
expect(grading?.adjust.contrast).toBe(0.2);
expect(grading?.adjust.saturation).toBeGreaterThan(0);
});
it("clamps values to supported shader ranges", () => {
const grading = normalizeHfColorGrading({
intensity: 2,
adjust: { exposure: 10, contrast: -5, vibrance: 3, saturation: 3 },
details: {
vignette: 2,
vignetteMidpoint: -1,
vignetteRoundness: 2,
vignetteFeather: 2,
grain: -1,
grainSize: 2,
grainRoughness: -1,
},
effects: { blur: 2, pixelate: 3 },
lut: { src: "looks/test.cube", intensity: 3 },
});
expect(grading?.intensity).toBe(1);
expect(grading?.adjust.exposure).toBe(2);
expect(grading?.adjust.contrast).toBe(-1);
expect(grading?.adjust.vibrance).toBe(1);
expect(grading?.adjust.saturation).toBe(1);
expect(grading?.details.vignette).toBe(1);
expect(grading?.details.vignetteMidpoint).toBe(0);
expect(grading?.details.vignetteRoundness).toBe(1);
expect(grading?.details.vignetteFeather).toBe(1);
expect(grading?.details.grain).toBe(0);
expect(grading?.details.grainSize).toBe(1);
expect(grading?.details.grainRoughness).toBe(0);
expect(grading?.effects.blur).toBe(1);
expect(grading?.effects.pixelate).toBe(1);
expect(grading?.lut?.intensity).toBe(1);
});
it("returns null for disabled or invalid grading", () => {
expect(normalizeHfColorGrading({ enabled: false, preset: "warm-clean" })).toBeNull();
expect(normalizeHfColorGrading("{nope")).toBeNull();
expect(normalizeHfColorGrading("")).toBeNull();
});
it("serializes normalized grading for data-color-grading", () => {
const grading = normalizeHfColorGrading({
adjust: { exposure: 0.25 },
details: { vignette: 0.3, grain: 0.1 },
effects: { blur: 0.2, pixelate: 0.4 },
lut: { src: "assets/luts/test.cube", intensity: 0.6 },
});
const serialized = serializeHfColorGrading(grading);
expect(serialized).toContain('"exposure":0.25');
expect(serialized).toContain('"vignette":0.3');
expect(serialized).toContain('"grain":0.1');
expect(serialized).toContain('"blur":0.2');
expect(serialized).toContain('"pixelate":0.4');
expect(serialized).toContain('"src":"assets/luts/test.cube"');
expect(normalizeHfColorGrading(serialized)?.adjust.exposure).toBe(0.25);
expect(normalizeHfColorGrading(serialized)?.details.vignette).toBe(0.3);
expect(normalizeHfColorGrading(serialized)?.effects.blur).toBe(0.2);
expect(normalizeHfColorGrading(serialized)?.lut?.intensity).toBe(0.6);
});
it("treats zero global intensity as inactive even with LUT data", () => {
const grading = normalizeHfColorGrading({
intensity: 0,
adjust: { exposure: 0.5 },
lut: { src: "assets/luts/test.cube", intensity: 1 },
});
expect(isHfColorGradingActive(grading)).toBe(false);
});
it("treats finishing details as active grading", () => {
const grading = normalizeHfColorGrading({ details: { vignette: 0.2 } });
expect(isHfColorGradingActive(grading)).toBe(true);
});
it("does not activate grading for advanced finishing defaults alone", () => {
const grading = normalizeHfColorGrading({
details: { vignetteMidpoint: 0.2, grainSize: 0.8 },
});
expect(isHfColorGradingActive(grading)).toBe(false);
});
it("treats media effects as active grading", () => {
const grading = normalizeHfColorGrading({ effects: { blur: 0.2 } });
expect(isHfColorGradingActive(grading)).toBe(true);
});
it("resolves exact variable references inside color grading JSON", () => {
const grading = normalizeHfColorGradingWithVariables(
JSON.stringify({
preset: "$preset",
intensity: "$gradingIntensity",
adjust: {
exposure: "${exposure}",
vibrance: "$vibrance",
saturation: "$saturation",
},
details: {
vignette: "$vignette",
grainSize: "$grainSize",
},
effects: { pixelate: "$pixelate" },
lut: {
src: "$lutSrc",
intensity: "$lutIntensity",
},
}),
{
preset: "warm-clean",
gradingIntensity: 0.6,
exposure: 0.25,
vibrance: 0.3,
saturation: -0.2,
vignette: 0.15,
grainSize: 0.4,
pixelate: 0.1,
lutSrc: "assets/luts/warm.cube",
lutIntensity: 0.4,
},
);
expect(grading?.preset).toBe("warm-clean");
expect(grading?.intensity).toBe(0.6);
expect(grading?.adjust.exposure).toBe(0.25);
expect(grading?.adjust.vibrance).toBe(0.3);
expect(grading?.adjust.saturation).toBe(-0.2);
expect(grading?.details.vignette).toBe(0.15);
expect(grading?.details.grainSize).toBe(0.4);
expect(grading?.effects.pixelate).toBe(0.1);
expect(grading?.lut).toEqual({ src: "assets/luts/warm.cube", intensity: 0.4 });
});
it("supports a whole grading supplied by one variable", () => {
const grading = normalizeHfColorGradingWithVariables("$colorGrade", {
colorGrade: {
adjust: { contrast: 0.2 },
lut: { src: "assets/luts/natural-boost.cube", intensity: 0.75 },
},
});
expect(grading?.adjust.contrast).toBe(0.2);
expect(grading?.lut).toEqual({ src: "assets/luts/natural-boost.cube", intensity: 0.75 });
});
});
+539
View File
@@ -0,0 +1,539 @@
export const HF_COLOR_GRADING_ATTR = "data-color-grading";
// Runtime <-> studio contract attributes. The runtime grading engine writes
// them; studio editing/soft-reload code reads them. Single owner — never
// re-declare these literals elsewhere.
/** Set on a graded source while its pixels render on the grading canvas. */
export const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden";
/**
* The element's AUTHORED inline opacity, stamped at document parse time before
* any animation engine mutates it ("" = authored none; attribute absent =
* never captured). See installAuthoredOpacityCapture in the runtime.
*/
export const COLOR_GRADING_AUTHORED_OPACITY_ATTR = "data-hf-authored-opacity";
export const HF_COLOR_GRADING_CANVAS_ID_PREFIX = "__hf_color_grading_";
export const HF_COLOR_GRADING_COLOR_SPACE = "rec709";
export type HfColorGradingPresetId =
| "neutral"
| "natural-lift"
| "fresh-pop"
| "warm-daylight"
| "clean-studio"
| "skin-soft"
| "food-pop"
| "night-lift"
| "muted-editorial"
| "vintage-wash"
| "mono-clean"
| "mono-fade"
| "warm-clean"
| "cool-clean"
| "soft-boost"
| "bright-pop"
| "deep-contrast";
export type HfColorGradingAdjustKey =
| "exposure"
| "contrast"
| "highlights"
| "shadows"
| "whites"
| "blacks"
| "temperature"
| "tint"
| "vibrance"
| "saturation";
export type HfColorGradingAdjust = Partial<Record<HfColorGradingAdjustKey, number>>;
export type HfColorGradingDetailKey =
| "vignette"
| "vignetteMidpoint"
| "vignetteRoundness"
| "vignetteFeather"
| "grain"
| "grainSize"
| "grainRoughness";
export type HfColorGradingDetails = Partial<Record<HfColorGradingDetailKey, number>>;
export type HfColorGradingEffectKey = "blur" | "pixelate";
export type HfColorGradingEffects = Partial<Record<HfColorGradingEffectKey, number>>;
export interface HfColorGradingLutRef {
src: string;
intensity?: number;
}
export interface HfColorGrading {
enabled?: boolean;
preset?: HfColorGradingPresetId | string | null;
intensity?: number;
adjust?: HfColorGradingAdjust;
details?: HfColorGradingDetails;
effects?: HfColorGradingEffects;
lut?: HfColorGradingLutRef | string | null;
colorSpace?: typeof HF_COLOR_GRADING_COLOR_SPACE | string;
}
export interface NormalizedHfColorGrading {
enabled: boolean;
preset: HfColorGradingPresetId | string | null;
intensity: number;
adjust: Record<HfColorGradingAdjustKey, number>;
details: Record<HfColorGradingDetailKey, number>;
effects: Record<HfColorGradingEffectKey, number>;
lut: HfColorGradingLutRef | null;
colorSpace: typeof HF_COLOR_GRADING_COLOR_SPACE | string;
}
export interface HfColorGradingTarget {
id?: string | null;
hfId?: string | null;
selector?: string | null;
selectorIndex?: number | null;
}
export interface HfColorGradingPreset {
id: HfColorGradingPresetId;
label: string;
adjust: Record<HfColorGradingAdjustKey, number>;
details: Record<HfColorGradingDetailKey, number>;
effects: Record<HfColorGradingEffectKey, number>;
}
export type HfColorGradingVariableMap = Record<string, unknown>;
const ADJUST_ZERO: Record<HfColorGradingAdjustKey, number> = {
exposure: 0,
contrast: 0,
highlights: 0,
shadows: 0,
whites: 0,
blacks: 0,
temperature: 0,
tint: 0,
vibrance: 0,
saturation: 0,
};
// Detail sub-controls keep identity-state defaults so enabling vignette/grain starts from useful
// perceptual settings instead of raw mathematical zeroes.
const DETAIL_ZERO: Record<HfColorGradingDetailKey, number> = {
vignette: 0,
vignetteMidpoint: 0.5,
vignetteRoundness: 0,
vignetteFeather: 0.65,
grain: 0,
grainSize: 0.25,
grainRoughness: 0.5,
};
const EFFECT_ZERO: Record<HfColorGradingEffectKey, number> = {
blur: 0,
pixelate: 0,
};
export const HF_COLOR_GRADING_ADJUST_KEYS = Object.keys(
ADJUST_ZERO,
) as readonly HfColorGradingAdjustKey[];
export const HF_COLOR_GRADING_DETAIL_KEYS = Object.keys(
DETAIL_ZERO,
) as readonly HfColorGradingDetailKey[];
export const HF_COLOR_GRADING_EFFECT_KEYS = Object.keys(
EFFECT_ZERO,
) as readonly HfColorGradingEffectKey[];
function preset(
id: HfColorGradingPresetId,
label: string,
adjust: HfColorGradingAdjust = {},
details: HfColorGradingDetails = {},
): HfColorGradingPreset {
return {
id,
label,
adjust: { ...ADJUST_ZERO, ...adjust },
details: { ...DETAIL_ZERO, ...details },
effects: { ...EFFECT_ZERO },
};
}
export const HF_COLOR_GRADING_PRESETS: readonly HfColorGradingPreset[] = [
preset("neutral", "Neutral"),
preset("natural-lift", "Natural Lift", {
exposure: 0.04,
contrast: 0.06,
highlights: -0.06,
shadows: 0.08,
saturation: 0.05,
}),
preset("fresh-pop", "Fresh Pop", {
exposure: 0.08,
contrast: 0.12,
whites: 0.06,
shadows: 0.04,
temperature: -0.02,
vibrance: 0.08,
saturation: 0.16,
}),
preset("warm-daylight", "Warm Daylight", {
exposure: 0.06,
contrast: 0.07,
highlights: -0.06,
shadows: 0.08,
temperature: 0.18,
saturation: 0.08,
}),
preset("clean-studio", "Clean Studio", {
contrast: 0.08,
highlights: -0.08,
shadows: 0.06,
temperature: -0.08,
tint: 0.03,
saturation: 0.04,
}),
preset("skin-soft", "Skin Soft", {
exposure: 0.04,
contrast: -0.03,
highlights: -0.12,
shadows: 0.12,
temperature: 0.08,
tint: 0.02,
saturation: 0.04,
}),
preset("food-pop", "Food Pop", {
exposure: 0.06,
contrast: 0.1,
shadows: 0.06,
temperature: 0.14,
vibrance: 0.1,
saturation: 0.18,
}),
preset(
"night-lift",
"Night Lift",
{
exposure: 0.08,
contrast: 0.08,
highlights: -0.18,
shadows: 0.2,
blacks: -0.08,
saturation: 0.04,
},
{
vignette: 0.12,
},
),
preset(
"muted-editorial",
"Muted Editorial",
{
exposure: -0.02,
contrast: 0.08,
highlights: -0.08,
shadows: 0.06,
blacks: -0.05,
temperature: -0.03,
saturation: -0.12,
},
{
vignette: 0.1,
},
),
preset(
"vintage-wash",
"Vintage Wash",
{
exposure: 0.03,
contrast: -0.12,
highlights: -0.1,
shadows: 0.16,
whites: -0.04,
blacks: 0.08,
temperature: 0.13,
vibrance: -0.08,
saturation: -0.08,
},
{
vignette: 0.18,
},
),
preset("mono-clean", "Mono Clean", {
contrast: 0.12,
highlights: -0.04,
shadows: 0.04,
blacks: -0.08,
saturation: -1,
}),
preset(
"mono-fade",
"Mono Fade",
{
contrast: -0.04,
highlights: -0.06,
shadows: 0.1,
blacks: 0.12,
saturation: -1,
},
{
vignette: 0.08,
},
),
preset("warm-clean", "Warm Clean", {
exposure: 0.05,
contrast: 0.08,
highlights: -0.08,
shadows: 0.08,
temperature: 0.16,
vibrance: 0.04,
saturation: 0.06,
}),
preset("cool-clean", "Cool Clean", {
contrast: 0.06,
highlights: -0.06,
shadows: 0.06,
temperature: -0.12,
tint: 0.04,
saturation: 0.04,
}),
preset("soft-boost", "Soft Boost", {
exposure: 0.06,
contrast: -0.04,
highlights: -0.14,
shadows: 0.16,
vibrance: 0.08,
saturation: 0.1,
}),
preset("bright-pop", "Bright Pop", {
exposure: 0.12,
contrast: 0.12,
whites: 0.08,
blacks: -0.04,
vibrance: 0.08,
saturation: 0.14,
}),
preset("deep-contrast", "Deep Contrast", {
exposure: -0.03,
contrast: 0.2,
highlights: -0.08,
shadows: -0.08,
blacks: -0.12,
saturation: 0.06,
}),
];
const PRESETS_BY_ID = new Map<string, HfColorGradingPreset>(
HF_COLOR_GRADING_PRESETS.map((preset) => [preset.id, preset]),
);
const VARIABLE_REF_RE = /^\$(?:\{([A-Za-z0-9_.:-]+)\}|([A-Za-z0-9_.:-]+))$/;
const ADJUST_LIMITS: Record<HfColorGradingAdjustKey, { min: number; max: number }> = {
exposure: { min: -2, max: 2 },
contrast: { min: -1, max: 1 },
highlights: { min: -1, max: 1 },
shadows: { min: -1, max: 1 },
whites: { min: -1, max: 1 },
blacks: { min: -1, max: 1 },
temperature: { min: -1, max: 1 },
tint: { min: -1, max: 1 },
vibrance: { min: -1, max: 1 },
saturation: { min: -1, max: 1 },
};
const DETAIL_LIMITS: Record<HfColorGradingDetailKey, { min: number; max: number }> = {
vignette: { min: 0, max: 1 },
vignetteMidpoint: { min: 0, max: 1 },
vignetteRoundness: { min: -1, max: 1 },
vignetteFeather: { min: 0, max: 1 },
grain: { min: 0, max: 1 },
grainSize: { min: 0, max: 1 },
grainRoughness: { min: 0, max: 1 },
};
const EFFECT_LIMITS: Record<HfColorGradingEffectKey, { min: number; max: number }> = {
blur: { min: 0, max: 1 },
pixelate: { min: 0, max: 1 },
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(max, Math.max(min, value));
}
function clampUnit(value: unknown, fallback: number): number {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(1, Math.max(0, parsed));
}
function readLimitedValue(value: unknown, limit: { min: number; max: number }): number {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed)) return 0;
return clamp(parsed, limit.min, limit.max);
}
function normalizePresetId(value: unknown): HfColorGradingPresetId | string | null {
if (value == null) return null;
const preset = String(value).trim();
return preset ? preset : null;
}
function normalizeLut(value: unknown): HfColorGradingLutRef | null {
if (value == null) return null;
if (typeof value === "string") {
const src = value.trim();
return src ? { src, intensity: 1 } : null;
}
if (!isRecord(value)) return null;
const rawSrc = value.src;
if (typeof rawSrc !== "string" || rawSrc.trim() === "") return null;
return {
src: rawSrc.trim(),
intensity: clampUnit(value.intensity, 1),
};
}
function readColorGradingObject(raw: unknown): Record<string, unknown> | null {
if (typeof raw === "string") {
const trimmed = raw.trim();
if (!trimmed) return null;
if (trimmed.startsWith("{")) {
try {
const parsed: unknown = JSON.parse(trimmed);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
return { preset: trimmed, intensity: 1 };
}
return isRecord(raw) ? raw : null;
}
function resolveStringVariableRef(value: string, variables: HfColorGradingVariableMap): unknown {
const match = value.trim().match(VARIABLE_REF_RE);
if (!match) return value;
const key = match[1] ?? match[2] ?? "";
return key && Object.hasOwn(variables, key) ? variables[key] : value;
}
export function resolveHfColorGradingVariables(
raw: unknown,
variables: HfColorGradingVariableMap,
): unknown {
if (typeof raw === "string") {
const direct = resolveStringVariableRef(raw, variables);
if (direct !== raw) return direct;
const trimmed = raw.trim();
if (!trimmed.startsWith("{")) return raw;
try {
return resolveHfColorGradingVariables(JSON.parse(trimmed) as unknown, variables);
} catch {
return raw;
}
}
if (!isRecord(raw)) return raw;
const resolved: Record<string, unknown> = {};
for (const [key, value] of Object.entries(raw)) {
resolved[key] = resolveHfColorGradingVariables(value, variables);
}
return resolved;
}
function getHfColorGradingPreset(id: string | null | undefined): HfColorGradingPreset | null {
if (!id) return null;
return PRESETS_BY_ID.get(id) ?? null;
}
export function normalizeHfColorGrading(raw: unknown): NormalizedHfColorGrading | null {
const grading = readColorGradingObject(raw);
if (!grading) return null;
if (grading.enabled === false) return null;
const presetId = normalizePresetId(grading.preset);
const preset = getHfColorGradingPreset(presetId);
const presetAdjust = preset?.adjust ?? ADJUST_ZERO;
const presetDetails = preset?.details ?? DETAIL_ZERO;
const presetEffects = preset?.effects ?? EFFECT_ZERO;
const rawAdjust = isRecord(grading.adjust) ? grading.adjust : {};
const rawDetails = isRecord(grading.details) ? grading.details : {};
const rawEffects = isRecord(grading.effects) ? grading.effects : {};
const adjust = HF_COLOR_GRADING_ADJUST_KEYS.reduce<Record<HfColorGradingAdjustKey, number>>(
(result, key) => {
result[key] = readLimitedValue(rawAdjust[key] ?? presetAdjust[key], ADJUST_LIMITS[key]);
return result;
},
{ ...ADJUST_ZERO },
);
const details = HF_COLOR_GRADING_DETAIL_KEYS.reduce<Record<HfColorGradingDetailKey, number>>(
(result, key) => {
result[key] = readLimitedValue(rawDetails[key] ?? presetDetails[key], DETAIL_LIMITS[key]);
return result;
},
{ ...DETAIL_ZERO },
);
const effects = HF_COLOR_GRADING_EFFECT_KEYS.reduce<Record<HfColorGradingEffectKey, number>>(
(result, key) => {
result[key] = readLimitedValue(rawEffects[key] ?? presetEffects[key], EFFECT_LIMITS[key]);
return result;
},
{ ...EFFECT_ZERO },
);
return {
enabled: true,
preset: presetId,
intensity: clampUnit(grading.intensity, 1),
adjust,
details,
effects,
lut: normalizeLut(grading.lut),
colorSpace:
typeof grading.colorSpace === "string" && grading.colorSpace.trim()
? grading.colorSpace.trim()
: HF_COLOR_GRADING_COLOR_SPACE,
};
}
export function normalizeHfColorGradingWithVariables(
raw: unknown,
variables: HfColorGradingVariableMap,
): NormalizedHfColorGrading | null {
return normalizeHfColorGrading(resolveHfColorGradingVariables(raw, variables));
}
export function serializeHfColorGrading(
grading: NormalizedHfColorGrading | HfColorGrading | null,
): string {
const normalized = normalizeHfColorGrading(grading);
if (!normalized) return "";
const { enabled: _enabled, ...serializable } = normalized;
return JSON.stringify(serializable);
}
export function isHfColorGradingActive(
grading: NormalizedHfColorGrading | null,
): grading is NormalizedHfColorGrading {
if (!grading?.enabled) return false;
if (grading.intensity === 0) return false;
if (grading.lut && grading.lut.intensity !== 0) return true;
return (
HF_COLOR_GRADING_ADJUST_KEYS.some((key) => Math.abs(grading.adjust[key]) > 0.0001) ||
Math.abs(grading.details.vignette) > 0.0001 ||
Math.abs(grading.details.grain) > 0.0001 ||
HF_COLOR_GRADING_EFFECT_KEYS.some((key) => Math.abs(grading.effects[key]) > 0.0001)
);
}
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import { CubeLutParseError, packCubeLutToRgba8, parseCubeLut } from "./colorLuts";
const IDENTITY_2 = `
# comment
TITLE "Identity 2"
DOMAIN_MIN 0 0 0
DOMAIN_MAX 1 1 1
LUT_3D_SIZE 2
0 0 0
1 0 0
0 1 0
1 1 0
0 0 1
1 0 1
0 1 1
1 1 1
`;
describe("cube LUT parsing", () => {
it("parses a 3D cube LUT with title, domain, and red-fastest data order", () => {
const lut = parseCubeLut(IDENTITY_2);
expect(lut.title).toBe("Identity 2");
expect(lut.size).toBe(2);
expect(lut.domainMin).toEqual([0, 0, 0]);
expect(lut.domainMax).toEqual([1, 1, 1]);
expect(Array.from(lut.data.slice(0, 12))).toEqual([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0]);
});
it("packs 3D LUT data into a WebGL1-friendly 2D RGBA texture", () => {
const packed = packCubeLutToRgba8(parseCubeLut(IDENTITY_2));
expect(packed.width).toBe(4);
expect(packed.height).toBe(2);
expect(Array.from(packed.data.slice(0, 16))).toEqual([
0, 0, 0, 255, 255, 0, 0, 255, 0, 0, 255, 255, 255, 0, 255, 255,
]);
});
it("accepts DaVinci/IRIDAS-style 3D input ranges", () => {
const lut = parseCubeLut(`
TITLE "Resolve Range"
LUT_3D_SIZE 2
LUT_3D_INPUT_RANGE 0 1
0 0 0
1 0 0
0 1 0
1 1 0
0 0 1
1 0 1
0 1 1
1 1 1
`);
expect(lut.title).toBe("Resolve Range");
expect(lut.domainMin).toEqual([0, 0, 0]);
expect(lut.domainMax).toEqual([1, 1, 1]);
});
it("rejects inverted 3D input ranges at the range line", () => {
expect(() =>
parseCubeLut(`
LUT_3D_SIZE 2
LUT_3D_INPUT_RANGE 1 0
`),
).toThrow("LUT_3D_INPUT_RANGE max must exceed min");
});
it("rejects unsupported 1D cube LUTs", () => {
expect(() =>
parseCubeLut(`
LUT_1D_SIZE 2
0 0 0
1 1 1
`),
).toThrow("1D cube LUTs are not supported yet");
});
it("rejects mixed 1D and 3D cube LUTs", () => {
expect(() =>
parseCubeLut(`
LUT_1D_SIZE 2
LUT_3D_SIZE 2
0 0 0
1 0 0
0 1 0
1 1 0
0 0 1
1 0 1
0 1 1
1 1 1
`),
).toThrow("Mixed 1D and 3D cube LUTs are not supported yet");
});
it("rejects row count mismatches and oversize LUTs", () => {
expect(() =>
parseCubeLut(`
LUT_3D_SIZE 2
0 0 0
`),
).toThrow("Expected 8 LUT rows");
expect(() => parseCubeLut("LUT_3D_SIZE 65", { maxSize: 64 })).toThrow(
"LUT_3D_SIZE 65 exceeds max 64",
);
});
it("reports invalid numbers as cube parse errors", () => {
expect(() =>
parseCubeLut(`
LUT_3D_SIZE 2
nope 0 0
`),
).toThrow(CubeLutParseError);
});
});
+226
View File
@@ -0,0 +1,226 @@
export type CubeLutVec3 = readonly [number, number, number];
export interface CubeLut3D {
title: string | null;
size: number;
domainMin: CubeLutVec3;
domainMax: CubeLutVec3;
data: Float32Array;
}
export interface PackedCubeLut2D {
width: number;
height: number;
data: Uint8Array;
}
export interface ParseCubeLutOptions {
maxSize?: number;
}
export class CubeLutParseError extends Error {
readonly lineNumber: number | null;
constructor(message: string, lineNumber: number | null = null) {
super(lineNumber == null ? message : `${message} at line ${lineNumber}`);
this.name = "CubeLutParseError";
this.lineNumber = lineNumber;
}
}
const DEFAULT_DOMAIN_MIN: CubeLutVec3 = [0, 0, 0];
const DEFAULT_DOMAIN_MAX: CubeLutVec3 = [1, 1, 1];
export const DEFAULT_MAX_CUBE_LUT_SIZE = 64;
function stripComment(line: string): string {
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') inQuote = !inQuote;
if (char === "#" && !inQuote) return line.slice(0, i);
}
return line;
}
function parseFiniteNumber(value: string, lineNumber: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new CubeLutParseError(`Invalid number "${value}"`, lineNumber);
}
return parsed;
}
function parseVec3(parts: string[], keyword: string, lineNumber: number): CubeLutVec3 {
if (parts.length !== 3) {
throw new CubeLutParseError(`${keyword} expects three numbers`, lineNumber);
}
return [
parseFiniteNumber(parts[0]!, lineNumber),
parseFiniteNumber(parts[1]!, lineNumber),
parseFiniteNumber(parts[2]!, lineNumber),
];
}
function parseSize(value: string | undefined, keyword: string, lineNumber: number): number {
if (!value) throw new CubeLutParseError(`${keyword} expects a size`, lineNumber);
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 2) {
throw new CubeLutParseError(`${keyword} must be an integer greater than 1`, lineNumber);
}
return parsed;
}
function validateDomain(domainMin: CubeLutVec3, domainMax: CubeLutVec3): void {
if (
domainMax[0] <= domainMin[0] ||
domainMax[1] <= domainMin[1] ||
domainMax[2] <= domainMin[2]
) {
throw new CubeLutParseError("DOMAIN_MAX values must be greater than DOMAIN_MIN values");
}
}
function parseTitle(line: string): string | null {
const quoted = /^TITLE\s+"([^"]*)"\s*$/i.exec(line);
if (quoted) return quoted[1] ?? null;
const bare = /^TITLE\s+(.+)\s*$/i.exec(line);
return bare ? (bare[1] ?? "").trim() || null : null;
}
function isNumericDataLine(token: string): boolean {
return /^[+-]?(?:\d|\.\d)/.test(token);
}
// fallow-ignore-next-line complexity
export function parseCubeLut(input: string, options: ParseCubeLutOptions = {}): CubeLut3D {
const maxSize = options.maxSize ?? DEFAULT_MAX_CUBE_LUT_SIZE;
let title: string | null = null;
let domainMin: CubeLutVec3 = DEFAULT_DOMAIN_MIN;
let domainMax: CubeLutVec3 = DEFAULT_DOMAIN_MAX;
let lut1dSize: number | null = null;
let lut3dSize: number | null = null;
const rows: number[] = [];
const lines = input.replace(/^\uFEFF/, "").split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const lineNumber = i + 1;
const line = stripComment(lines[i] ?? "").trim();
if (!line) continue;
const parts = line.split(/\s+/);
const keyword = (parts[0] ?? "").toUpperCase();
const rest = parts.slice(1);
if (keyword === "TITLE") {
title = parseTitle(line);
continue;
}
if (keyword === "DOMAIN_MIN") {
domainMin = parseVec3(rest, keyword, lineNumber);
continue;
}
if (keyword === "DOMAIN_MAX") {
domainMax = parseVec3(rest, keyword, lineNumber);
continue;
}
if (keyword === "LUT_3D_INPUT_RANGE") {
if (rest.length !== 2) {
throw new CubeLutParseError(`${keyword} expects two numbers`, lineNumber);
}
const min = parseFiniteNumber(rest[0]!, lineNumber);
const max = parseFiniteNumber(rest[1]!, lineNumber);
if (max <= min) {
throw new CubeLutParseError("LUT_3D_INPUT_RANGE max must exceed min", lineNumber);
}
domainMin = [min, min, min];
domainMax = [max, max, max];
continue;
}
if (keyword === "LUT_1D_SIZE") {
lut1dSize = parseSize(rest[0], keyword, lineNumber);
continue;
}
if (keyword === "LUT_3D_SIZE") {
lut3dSize = parseSize(rest[0], keyword, lineNumber);
if (lut3dSize > maxSize) {
throw new CubeLutParseError(`LUT_3D_SIZE ${lut3dSize} exceeds max ${maxSize}`, lineNumber);
}
continue;
}
if (!isNumericDataLine(keyword)) {
if (keyword.startsWith("LUT_")) {
throw new CubeLutParseError(`Unsupported cube keyword ${keyword}`, lineNumber);
}
continue;
}
if (!lut3dSize) {
if (lut1dSize) {
throw new CubeLutParseError("1D cube LUTs are not supported yet", lineNumber);
}
throw new CubeLutParseError("LUT data appears before LUT_3D_SIZE", lineNumber);
}
if (parts.length !== 3) {
throw new CubeLutParseError("LUT data rows must contain three numbers", lineNumber);
}
rows.push(
parseFiniteNumber(parts[0]!, lineNumber),
parseFiniteNumber(parts[1]!, lineNumber),
parseFiniteNumber(parts[2]!, lineNumber),
);
}
if (lut1dSize && lut3dSize) {
throw new CubeLutParseError("Mixed 1D and 3D cube LUTs are not supported yet");
}
if (!lut3dSize) {
if (lut1dSize) throw new CubeLutParseError("1D cube LUTs are not supported yet");
throw new CubeLutParseError("Missing LUT_3D_SIZE");
}
validateDomain(domainMin, domainMax);
const expectedRows = lut3dSize * lut3dSize * lut3dSize;
if (rows.length !== expectedRows * 3) {
throw new CubeLutParseError(
`Expected ${expectedRows} LUT rows for size ${lut3dSize}, found ${rows.length / 3}`,
);
}
return {
title,
size: lut3dSize,
domainMin,
domainMax,
data: new Float32Array(rows),
};
}
function clampUnit(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(1, Math.max(0, value));
}
function toByte(value: number): number {
return Math.round(clampUnit(value) * 255);
}
export function packCubeLutToRgba8(lut: CubeLut3D): PackedCubeLut2D {
const size = lut.size;
const width = size * size;
const height = size;
const packed = new Uint8Array(width * height * 4);
for (let b = 0; b < size; b++) {
for (let g = 0; g < size; g++) {
for (let r = 0; r < size; r++) {
const lutIndex = ((b * size + g) * size + r) * 3;
const pixelIndex = (g * width + b * size + r) * 4;
packed[pixelIndex] = toByte(lut.data[lutIndex] ?? 0);
packed[pixelIndex + 1] = toByte(lut.data[lutIndex + 1] ?? 0);
packed[pixelIndex + 2] = toByte(lut.data[lutIndex + 2] ?? 0);
packed[pixelIndex + 3] = 255;
}
}
}
return { width, height, data: packed };
}
+7
View File
@@ -0,0 +1,7 @@
// Moved to @hyperframes/parsers. Re-exported here for back-compat.
export {
CSS_URL_RE,
PATH_ATTRS,
isNonRelativeUrl,
isPathInside,
} from "@hyperframes/parsers/asset-paths";
@@ -0,0 +1,841 @@
import { describe, expect, it, vi } from "vitest";
import { parseHTML } from "linkedom";
import {
scopeCssToComposition,
wrapInlineScriptWithErrorBoundary,
wrapScopedCompositionScript,
} from "./compositionScoping";
describe("composition scoping", () => {
it("scopes regular selectors while preserving global at-rules", () => {
const scoped = scopeCssToComposition(
`
@import url("https://example.com/font.css");
.title, .card:hover { opacity: 0; }
@media (min-width: 800px) {
.title { transform: translateY(30px); }
}
@keyframes rise {
from { opacity: 0; }
to { opacity: 1; }
}
[data-composition-id="scene"] .already { color: red; }
body { margin: 0; }
`,
"scene",
);
expect(scoped).toContain('@import url("https://example.com/font.css");');
expect(scoped).toContain(
'[data-composition-id="scene"] .title, [data-composition-id="scene"] .card:hover',
);
expect(scoped).toContain('[data-composition-id="scene"] .title { transform');
expect(scoped).toContain("@keyframes rise");
expect(scoped).toContain("from { opacity: 0; }");
expect(scoped).toContain('[data-composition-id="scene"] .already { color: red; }');
expect(scoped).toContain("body { margin: 0; }");
});
it("leaves html/body/:root untouched by default (top-level composition owns the document)", () => {
const scoped = scopeCssToComposition(
`html, body { width: 560px; height: 360px; overflow: hidden; }\n:root { --x: 1; }`,
"scene",
);
expect(scoped).toContain("html, body { width: 560px");
expect(scoped).toContain(":root { --x: 1; }");
});
it("remaps html/body/:root to the composition box when scopeRootSelectors is set (sub-comp mount/inline)", () => {
const scoped = scopeCssToComposition(
`html, body { width: 560px; height: 360px; overflow: hidden; background: #14141c; }\n:root { color: red; }\n.title { opacity: 0; }`,
"scene",
undefined,
undefined,
{ scopeRootSelectors: true },
);
// No bare document-level selectors survive — they must not clobber the host document's body.
expect(scoped).not.toMatch(/(^|[\s,{})])html\s*[,{]/);
expect(scoped).not.toMatch(/(^|[\s,{})]):root\s*\{/);
expect(scoped).not.toMatch(/(^|[\s,{}])body\s*\{/);
// They are remapped to the composition's own box (host or flattened inner root).
expect(scoped).toContain('[data-composition-id="scene"]');
expect(scoped).toContain("data-hf-inner-root");
expect(scoped).toContain("width: 560px");
// Regular selectors still scope as usual.
expect(scoped).toContain('[data-composition-id="scene"] .title { opacity: 0; }');
});
it("does not scope the universal selector even with scopeRootSelectors", () => {
const scoped = scopeCssToComposition(
`* { box-sizing: border-box; }`,
"scene",
undefined,
undefined,
{
scopeRootSelectors: true,
},
);
expect(scoped).toContain("* { box-sizing: border-box; }");
});
it("pins the concrete parent-body clobber pattern that motivated the fix", () => {
// The exact sub-comp rule that used to shrink the host <body> and clip the
// preview. Assert the concrete remapped output, not just abstract shape, so a
// future rewrite that reorders/splits declarations can't leave the shape
// assertions green while re-breaking this case.
const scoped = scopeCssToComposition(
`html, body { width: 560px; height: 360px; overflow: hidden; background: #14141c; }`,
"scene",
undefined,
undefined,
{ scopeRootSelectors: true },
);
expect(scoped).toContain('[data-composition-id="scene"]:not(:has([data-hf-inner-root]))');
expect(scoped).toContain('[data-composition-id="scene"] > [data-hf-inner-root]');
expect(scoped).toContain("width: 560px");
expect(scoped).toContain("overflow: hidden");
});
it("wraps classic scripts without render-loop requestAnimationFrame waits", () => {
const wrapped = wrapScopedCompositionScript("window.__ran = true;", "scene");
expect(wrapped).toContain('var __hfCompId = "scene";');
expect(wrapped).toContain("new Proxy(window.document");
expect(wrapped).toContain("new Proxy(__hfBaseGsap");
expect(wrapped).not.toContain("requestAnimationFrame");
});
it("normalizes root timing attributes when scoping selectors", () => {
const scoped = scopeCssToComposition(
'[data-composition-id="scene"][data-start="0"] .title { opacity: 0; }',
"scene",
);
expect(scoped).toContain('[data-composition-id="scene"] .title { opacity: 0; }');
expect(scoped).not.toContain('[data-start="0"]');
});
it("exposes a scoped __hyperframes.getVariables that reads __hfVariablesByComp[compId]", () => {
const { document } = parseHTML(`<div data-composition-id="card-1"></div>`);
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
__hfVariablesByComp: {
"card-1": { title: "Pro", price: "$29" },
"card-2": { title: "Enterprise", price: "Custom" },
},
__hyperframes: {
getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
fitTextFontSize: () => undefined,
},
};
const wrapped = wrapScopedCompositionScript(
`window.__captured = __hyperframes.getVariables();`,
"card-1",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" });
});
it("routes the documented window.__hyperframes.getVariables() to the scoped variant too", () => {
// Regression: the docs (variables-and-media.md) show `window.__hyperframes.
// getVariables()`, but inside a sub-comp the scoped `window` proxy used to
// fall through to the HOST page's base __hyperframes, returning the wrong
// (or empty) variables — the bare `__hyperframes` param was the only form
// that worked. Both spellings must now resolve to this comp's variables.
const { document } = parseHTML(`<div data-composition-id="card-1"></div>`);
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
__hfVariablesByComp: {
"card-1": { title: "Pro", price: "$29" },
"card-2": { title: "Enterprise", price: "Custom" },
},
__hyperframes: {
getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
fitTextFontSize: () => undefined,
},
};
const wrapped = wrapScopedCompositionScript(
`window.__captured = window.__hyperframes.getVariables();`,
"card-1",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" });
});
it("preserves non-getVariables members on window.__hyperframes (only getVariables is rescoped)", () => {
const { document } = parseHTML(`<div data-composition-id="card-1"></div>`);
let fitCalled = false;
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
__hfVariablesByComp: { "card-1": { title: "Pro" } },
__hyperframes: {
getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
fitTextFontSize: () => {
fitCalled = true;
},
},
};
const wrapped = wrapScopedCompositionScript(
`window.__hyperframes.fitTextFontSize();`,
"card-1",
);
new Function("window", wrapped)(fakeWindow);
expect(fitCalled).toBe(true);
});
it("scoped getVariables reads from the runtime composition id when it differs", () => {
const { document } = parseHTML(`<div data-composition-id="scene"></div>`);
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
__hfVariablesByComp: {
scene: { title: "Wrong" },
scene__hf1: { title: "Right" },
},
__hyperframes: {
getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
fitTextFontSize: () => undefined,
},
};
const wrapped = wrapScopedCompositionScript(
`window.__captured = __hyperframes.getVariables();`,
"scene",
"[HyperFrames] composition script error:",
undefined,
"scene__hf1",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__captured).toEqual({ title: "Right" });
});
it("scoped getVariables returns {} when __hfVariablesByComp has no entry for the comp", () => {
const { document } = parseHTML(`<div data-composition-id="missing"></div>`);
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
__hyperframes: {
getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
fitTextFontSize: () => undefined,
},
};
const wrapped = wrapScopedCompositionScript(
`window.__captured = __hyperframes.getVariables();`,
"missing",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__captured).toEqual({});
});
it("scoped getVariables returns a fresh object — mutations don't leak into the shared table", () => {
const { document } = parseHTML(`<div data-composition-id="card-1"></div>`);
const variablesByComp: Record<string, Record<string, unknown>> = {
"card-1": { title: "Pro" },
};
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
__hfVariablesByComp: variablesByComp,
__hyperframes: {
getVariables: () => ({}),
fitTextFontSize: () => undefined,
},
};
const wrapped = wrapScopedCompositionScript(
`var v = __hyperframes.getVariables(); v.title = "MUTATED"; v.added = "extra";`,
"card-1",
);
new Function("window", wrapped)(fakeWindow);
expect(variablesByComp["card-1"]).toEqual({ title: "Pro" });
});
it("preserves static methods on classes exposed through window", () => {
const { document } = parseHTML(`<div data-composition-id="scene"></div>`);
class FakeTexts {
static mountChars() {
return "ok";
}
}
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
Texts: FakeTexts,
};
const wrapped = wrapScopedCompositionScript(
`window.__capturedMountCharsType = typeof window.Texts?.mountChars;`,
"scene",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__capturedMountCharsType).toBe("function");
});
it("executes document and GSAP selectors inside the composition root", () => {
const { document } = parseHTML(`
<div data-composition-id="scene" data-start="intro"><h1 class="title">Scene</h1></div>
<div data-composition-id="other"><h1 class="title">Other</h1></div>
`);
const gsapTargets: string[][] = [];
const fakeWindow = {
document,
__selectedTitle: "",
__selectedRootTitle: "",
__timelines: {},
gsap: {
timeline: () => ({
to(targets: Element[]) {
gsapTargets.push(Array.from(targets).map((target) => target.textContent || ""));
return this;
},
}),
},
};
const wrapped = wrapScopedCompositionScript(
`
const tl = gsap.timeline({ paused: true });
tl.to('.title', { opacity: 1 });
tl.to('[data-composition-id="scene"][data-start="0"] .title', { opacity: 1 });
window.__selectedTitle = document.querySelector('.title')?.textContent || '';
window.__selectedRootTitle = document.querySelector('[data-composition-id="scene"][data-start="0"] .title')?.textContent || '';
window.__timelines.scene = tl;
`,
"scene",
);
new Function("window", "gsap", wrapped)(fakeWindow, fakeWindow.gsap);
expect(fakeWindow.__selectedTitle).toBe("Scene");
expect(fakeWindow.__selectedRootTitle).toBe("Scene");
expect(gsapTargets).toEqual([["Scene"], ["Scene"]]);
});
it("scopes getElementById when duplicate IDs exist across composition roots", () => {
const { document } = parseHTML(`
<div data-composition-id="scene-a"><canvas id="gl-canvas"></canvas></div>
<div data-composition-id="scene-b"><canvas id="gl-canvas"></canvas></div>
`);
const fakeWindow = {
document,
__selectedComp: "",
__timelines: {},
};
const wrapped = wrapScopedCompositionScript(
`
window.__selectedComp =
document.getElementById("gl-canvas")
?.closest("[data-composition-id]")
?.getAttribute("data-composition-id") || "null";
`,
"scene-b",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__selectedComp).toBe("scene-b");
});
it("scopes getElementById for IDs that need CSS selector escaping", () => {
const { document } = parseHTML(`
<div data-composition-id="scene-a"><div id="clip:1"></div></div>
<div data-composition-id="scene-b"><div id="clip:1"></div></div>
`);
const fakeWindow = {
document,
__selectedComp: "",
__timelines: {},
};
const wrapped = wrapScopedCompositionScript(
`
window.__selectedComp =
document.getElementById("clip:1")
?.closest("[data-composition-id]")
?.getAttribute("data-composition-id") || "null";
`,
"scene-b",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__selectedComp).toBe("scene-b");
});
it("scopes authored root id lookups after the flattened root drops its literal id", () => {
const { document } = parseHTML(`
<div data-composition-id="scene">
<div data-hf-authored-id="scene-root">
<h1 class="title">Scene</h1>
</div>
</div>
`);
const fakeWindow = {
document,
__selectedTitle: "",
__timelines: {},
};
const wrapped = wrapScopedCompositionScript(
`
window.__selectedTitle =
document.getElementById("scene-root")
?.querySelector(".title")
?.textContent || "missing";
`,
"scene",
"[HyperFrames] composition script error:",
undefined,
"scene",
"scene-root",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__selectedTitle).toBe("Scene");
});
it("does not rewrite authored root hash text inside CSS attribute values", () => {
const scoped = scopeCssToComposition(
'a[href="#scene-root"] { color: red; }',
"scene",
undefined,
"scene-root",
);
expect(scoped).toContain('[data-composition-id="scene"] a[href="#scene-root"]');
expect(scoped).not.toContain('[href="[data-hf-authored-id=');
});
it("does not rewrite authored root hash text inside querySelector attribute values", () => {
const { document } = parseHTML(`
<div data-composition-id="scene">
<a class="jump" href="#scene-root">Jump</a>
<div data-hf-authored-id="scene-root"></div>
</div>
`);
const fakeWindow = {
document,
__selectedHref: "",
__timelines: {},
};
const wrapped = wrapScopedCompositionScript(
`
window.__selectedHref =
document.querySelector('a[href="#scene-root"]')
?.getAttribute("href") || "missing";
`,
"scene",
"[HyperFrames] composition script error:",
undefined,
"scene",
"scene-root",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__selectedHref).toBe("#scene-root");
});
it("normalizes gsap.utils.selector() selectors for authored root ids and root timing attrs", () => {
const { document } = parseHTML(`
<div data-composition-id="scene" data-start="0">
<div data-hf-authored-id="scene-root">
<h1 class="title">Scene</h1>
</div>
</div>
<div data-composition-id="other" data-start="0">
<div data-hf-authored-id="scene-root">
<h1 class="title">Other</h1>
</div>
</div>
`);
const fakeWindow = {
document,
__selectedRootCount: 0,
__selectedTimedCount: 0,
__selectedTitle: "",
__timelines: {},
gsap: {
utils: {},
},
};
const wrapped = wrapScopedCompositionScript(
`
const select = gsap.utils.selector(document.querySelector('[data-composition-id="scene"]'));
window.__selectedRootCount = select('#scene-root').length;
window.__selectedTimedCount = select('[data-composition-id="scene"][data-start="0"] .title').length;
window.__selectedTitle = select('#scene-root .title')[0]?.textContent || "missing";
`,
"scene",
"[HyperFrames] composition script error:",
undefined,
"scene",
"scene-root",
);
new Function("window", "gsap", wrapped)(fakeWindow, fakeWindow.gsap);
expect(fakeWindow.__selectedRootCount).toBe(1);
expect(fakeWindow.__selectedTimedCount).toBe(1);
expect(fakeWindow.__selectedTitle).toBe("Scene");
});
it("reads scoped proxy accessors with the original target receiver", () => {
const root = {
contains(node: unknown) {
return node === root;
},
};
const body = { tagName: "BODY" };
const fakeDocument = {
querySelector(selector: string) {
return selector === '[data-composition-id="scene"]' ? root : null;
},
querySelectorAll() {
return [];
},
getElementById() {
return null;
},
get body() {
if (this !== fakeDocument) {
throw new TypeError("Illegal invocation");
}
return body;
},
};
const location = { href: "https://example.test/scene" };
const fakeUtils = {
get marker() {
if (this !== fakeUtils) {
throw new TypeError("Illegal invocation");
}
return "utils-ok";
},
};
const fakeGsap = {
utils: fakeUtils,
get version() {
if (this !== fakeGsap) {
throw new TypeError("Illegal invocation");
}
return "gsap-ok";
},
};
const fakeWindow = {
document: fakeDocument,
__bodyTag: "",
__href: "",
__windowSet: "",
__gsapVersion: "",
__utilsMarker: "",
__timelines: {},
gsap: fakeGsap,
get location() {
if (this !== fakeWindow) {
throw new TypeError("Illegal invocation");
}
return location;
},
set customValue(value: string) {
if (this !== fakeWindow) {
throw new TypeError("Illegal invocation");
}
this.__windowSet = value;
},
};
const wrapped = wrapScopedCompositionScript(
`
window.__bodyTag = document.body.tagName;
window.__href = window.location.href;
window.customValue = "window-set-ok";
window.__gsapVersion = gsap.version;
window.__utilsMarker = gsap.utils.marker;
`,
"scene",
);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
new Function("window", "gsap", wrapped)(fakeWindow, fakeWindow.gsap);
} finally {
errorSpy.mockRestore();
}
expect(fakeWindow.__bodyTag).toBe("BODY");
expect(fakeWindow.__href).toBe("https://example.test/scene");
expect(fakeWindow.__windowSet).toBe("window-set-ok");
expect(fakeWindow.__gsapVersion).toBe("gsap-ok");
expect(fakeWindow.__utilsMarker).toBe("utils-ok");
expect(errorSpy).not.toHaveBeenCalled();
});
it("reads remapped timeline registry accessors with the original target receiver", () => {
let timeline = "initial";
const timelineRegistry = {
get host() {
if (this !== timelineRegistry) {
throw new TypeError("Illegal invocation");
}
return timeline;
},
set host(value: string) {
if (this !== timelineRegistry) {
throw new TypeError("Illegal invocation");
}
timeline = value;
},
};
const fakeWindow = {
document: {
querySelector() {
return null;
},
querySelectorAll() {
return [];
},
},
__timelines: timelineRegistry,
__beforeTimeline: "",
__afterTimeline: "",
gsap: {},
};
const wrapped = wrapScopedCompositionScript(
`
window.__beforeTimeline = window.__timelines.scene;
window.__timelines.scene = "updated";
window.__afterTimeline = window.__timelines.scene;
`,
"scene",
"[HyperFrames] composition script error:",
undefined,
"host",
);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
new Function("window", "gsap", wrapped)(fakeWindow, fakeWindow.gsap);
} finally {
errorSpy.mockRestore();
}
expect(fakeWindow.__beforeTimeline).toBe("initial");
expect(fakeWindow.__afterTimeline).toBe("updated");
expect(errorSpy).not.toHaveBeenCalled();
});
it("uses compound selector when authored root is the scoped element itself", () => {
const scoped = scopeCssToComposition(
"#chrome-overlay-root { --primary: #FFDC8B; }",
"chrome-overlay",
undefined,
"chrome-overlay-root",
{ compoundAuthoredRoot: true },
);
// Both attributes are on the same element after inlining, so the selector
// must be compound (no space) to match.
expect(scoped).toContain(
'[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"]',
);
expect(scoped).not.toContain(
'[data-composition-id="chrome-overlay"] [data-hf-authored-id="chrome-overlay-root"]',
);
});
it("uses compound selector for authored root with descendant combinators", () => {
const scoped = scopeCssToComposition(
"#chrome-overlay-root .chrome { display: flex; }",
"chrome-overlay",
undefined,
"chrome-overlay-root",
{ compoundAuthoredRoot: true },
);
// The authored root part is compound with scope, .chrome is a descendant
expect(scoped).toContain(
'[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"] .chrome',
);
expect(scoped).not.toMatch(
/\[data-composition-id="chrome-overlay"\]\s+\[data-hf-authored-id="chrome-overlay-root"\]\s+\.chrome/,
);
});
it("still uses descendant selector for non-root selectors with authoredRootId", () => {
const scoped = scopeCssToComposition(
".child-element { color: red; }",
"chrome-overlay",
undefined,
"chrome-overlay-root",
);
// Regular child selectors still get a descendant combinator (space)
expect(scoped).toContain('[data-composition-id="chrome-overlay"] .child-element');
});
it("escapes </script> in scoped composition script source to prevent injection", () => {
const wrapped = wrapScopedCompositionScript(
'window.payload = "</script><script>window.pwned = true;</script>";',
"scene",
);
expect(wrapped).toContain("(function(document, gsap, window, __hyperframes)");
expect(wrapped).not.toContain("</script><script>");
expect(wrapped).toContain("<\\/script>");
});
it("wraps unscoped composition script source as a string literal", () => {
const wrapped = wrapInlineScriptWithErrorBoundary(
'window.payload = "</script><script>window.pwned = true;</script>";',
"[HyperFrames] composition script error:",
);
expect(wrapped).toContain("Function(");
expect(wrapped).toContain('\\"</script><script>window.pwned = true;</script>\\"');
});
it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
const scoped = scopeCssToComposition(
`#intro { background: #111; }
#intro .title { font-size: 120px; color: #fff; }`,
"intro",
undefined,
"intro",
);
// #intro should become [data-hf-authored-id="intro"]
expect(scoped).toContain('[data-hf-authored-id="intro"]');
expect(scoped).toContain('[data-hf-authored-id="intro"] .title');
// Raw #intro selectors should be gone
expect(scoped).not.toMatch(/#intro\b/);
});
it("rewrites a bare root [data-composition-id] box selector to target exactly one of host or wrapper", () => {
// A composition styling its own box (e.g. `display:flex` to center its
// children, or `padding` to offset it) via the bare composition-id
// selector. After flattenInnerRoot preserves the authored root as a
// wrapper below the host, that wrapper (marked data-hf-inner-root) is
// what actually parents the real children, so the box styling must land
// there instead of the host. It must land on exactly one of the two:
// targeting both would apply an additive property like `padding` twice,
// since the wrapper is nested inside the host.
const scoped = scopeCssToComposition(
'[data-composition-id="captions"] { display: flex; justify-content: center; }',
"captions",
);
expect(scoped).toContain(
'[data-composition-id="captions"]:not(:has([data-hf-inner-root])), ' +
'[data-composition-id="captions"] > [data-hf-inner-root]',
);
});
it("matches exactly the wrapper (not the host too) when both exist in the flattened DOM shape", () => {
// Regression test: an earlier version of this fix targeted both the host
// and the wrapper (a plain OR), which doubles any additive property
// (e.g. padding-top) since the wrapper is nested inside the host.
const scoped = scopeCssToComposition(
'[data-composition-id="captions"] { padding-top: 200px; }',
"captions",
);
const ruleMatch = scoped.match(/([^{]+)\{/);
const selectorText = ruleMatch?.[1]?.trim();
if (!selectorText) throw new Error("expected a CSS rule to be produced");
const { document } = parseHTML(
'<div id="host" data-composition-id="captions">' +
'<div id="wrapper" data-hf-inner-root="true"></div>' +
"</div>",
);
const matches = [...document.querySelectorAll(selectorText)];
expect(matches.map((el) => el.id)).toEqual(["wrapper"]);
});
it("matches the host when no wrapper is present (non-flattened fallback)", () => {
const scoped = scopeCssToComposition(
'[data-composition-id="captions"] { padding-top: 200px; }',
"captions",
);
const ruleMatch = scoped.match(/([^{]+)\{/);
const selectorText = ruleMatch?.[1]?.trim();
if (!selectorText) throw new Error("expected a CSS rule to be produced");
const { document } = parseHTML('<div id="host" data-composition-id="captions"></div>');
const matches = [...document.querySelectorAll(selectorText)];
expect(matches.map((el) => el.id)).toEqual(["host"]);
});
it("leaves root-plus-descendant [data-composition-id] selectors as a plain scope prefix", () => {
const scoped = scopeCssToComposition(
'[data-composition-id="captions"] .title { color: red; }',
"captions",
);
expect(scoped).toContain('[data-composition-id="captions"] .title');
expect(scoped).not.toContain("data-hf-inner-root");
});
it('does not rewrite [id="intro"] attribute selectors', () => {
// The function only targets #intro hash selectors, not [id="intro"] attribute selectors
const result = scopeCssToComposition(
'[id="intro"] .title { color: red; }',
"intro",
undefined,
"intro",
);
expect(result).toContain('[id="intro"]');
});
it("wraps scripts with authored root id normalization for #id GSAP selectors", () => {
const { document } = parseHTML(`
<div data-composition-id="intro">
<div data-hf-authored-id="intro">
<div class="title">HELLO</div>
</div>
</div>
`);
const gsapTargets: string[][] = [];
const fakeWindow = {
document,
__timelines: {},
gsap: {
timeline: () => ({
fromTo(targets: Element[], _from: unknown, _to: unknown) {
gsapTargets.push(Array.from(targets).map((t) => t.textContent || ""));
return this;
},
}),
},
};
const wrapped = wrapScopedCompositionScript(
`
var tl = gsap.timeline({ paused: true });
tl.fromTo('#intro .title', { opacity: 0 }, { opacity: 1, duration: 0.5 }, 0.2);
window.__timelines['intro'] = tl;
`,
"intro",
"[HyperFrames] composition script error:",
undefined,
"intro",
"intro",
);
new Function("window", "gsap", wrapped)(fakeWindow, fakeWindow.gsap);
// The scoped script should resolve '#intro .title' against the
// data-hf-authored-id="intro" element, finding the .title child.
expect(gsapTargets).toEqual([["HELLO"]]);
});
});
@@ -0,0 +1,571 @@
import postcss, { type AtRule, type Node, type Rule } from "postcss";
const AUTHORED_ROOT_ID_ATTR = "data-hf-authored-id";
const INNER_ROOT_ATTR = "data-hf-inner-root";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function escapeCssAttributeValue(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function escapeCssIdentifier(value: string): string {
if (!value) return value;
const escaped = value.replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`);
return escaped.replace(/^-?\d/, (match) => `\\${match}`);
}
function getAuthoredRootIdSelectorForms(authoredRootId: string): string[] {
const trimmed = authoredRootId.trim();
if (!trimmed) return [];
return Array.from(new Set([trimmed, escapeCssIdentifier(trimmed)])).filter(Boolean);
}
function isSelectorNameChar(char: string | undefined): boolean {
return !!char && /[\w-]/.test(char);
}
function replaceAuthoredRootIdSelectors(
selector: string,
authoredRootId: string,
replacement: string,
): string {
const forms = getAuthoredRootIdSelectorForms(authoredRootId).sort((a, b) => b.length - a.length);
if (forms.length === 0) return selector;
let result = "";
let bracketDepth = 0;
let quote: '"' | "'" | null = null;
for (let index = 0; index < selector.length; index += 1) {
const char = selector[index];
const previousChar = index > 0 ? selector[index - 1] : "";
if (quote) {
result += char;
if (char === quote && previousChar !== "\\") {
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
result += char;
continue;
}
if (char === "[") {
bracketDepth += 1;
result += char;
continue;
}
if (char === "]") {
bracketDepth = Math.max(0, bracketDepth - 1);
result += char;
continue;
}
if (char === "#" && bracketDepth === 0) {
const matchedForm = forms.find((form) => selector.startsWith(form, index + 1));
if (matchedForm) {
const nextChar = selector[index + 1 + matchedForm.length];
if (!isSelectorNameChar(nextChar)) {
result += replacement;
index += matchedForm.length;
continue;
}
}
}
result += char;
}
return result;
}
function normalizeAuthoredRootIdSelector(selector: string, authoredRootId?: string | null): string {
const trimmed = authoredRootId?.trim();
if (!trimmed) return selector;
return replaceAuthoredRootIdSelectors(
selector,
trimmed,
`[${AUTHORED_ROOT_ID_ATTR}="${escapeCssAttributeValue(trimmed)}"]`,
);
}
/** The composition's own box: the host when it renders content directly, or the
* flattened inner root when one is preserved below the host. Used both for a
* bare composition-root selector and for remapped document-level selectors.
* Relies on `:has()` (Chrome 105 / Safari 15.4 / Firefox 121) — an existing
* baseline for the bare-root case, noted here for new callers. */
function compositionBoxSelector(scope: string): string {
return `${scope}:not(:has([${INNER_ROOT_ATTR}])), ${scope} > [${INNER_ROOT_ATTR}]`;
}
function scopeSelector(
selector: string,
scope: string,
compositionId: string,
authoredRootId?: string | null,
compoundAuthoredRoot?: boolean,
scopeRootSelectors?: boolean,
): string {
const selectorWithoutAuthoredRootId = normalizeAuthoredRootIdSelector(selector, authoredRootId);
const selectorWithoutRootTiming = normalizeCompositionRootSelector(
selectorWithoutAuthoredRootId,
scope,
compositionId,
);
const trimmed = selectorWithoutRootTiming.trim();
if (!trimmed) return selector;
if (trimmed === "*") return selector;
if (/^(html|body|:root)$/i.test(trimmed)) {
// A mounted/inlined sub-comp's document-level selectors must not style the
// PARENT (a sub-comp `body { width/height/overflow }` would clobber the host
// <body> and clip the preview/render). Remap to the comp's own box. A
// top-level compile (scopeRootSelectors falsy) legitimately owns the document.
//
// Coverage is intentionally BARE-only: compound forms (`body.dark`,
// `body[data-theme]`, `body:hover`, `html body`, `:root .x`) fall through to
// general scoping below. That is byte-identical to pre-fix behavior — those
// selectors never matched the parent <body> (it has no data-composition-id),
// so there was no clobber to fix. The bare forms are the ones that actually
// caused the parent-body clobber, which is what this remap targets.
return scopeRootSelectors ? compositionBoxSelector(scope) : selector;
}
const compositionIdPattern = new RegExp(
`\\[\\s*data-composition-id\\s*=\\s*(["'])${escapeRegExp(compositionId)}\\1\\s*\\]`,
"g",
);
if (compositionIdPattern.test(trimmed)) {
const isRootBoxSelector = trimmed.replace(compositionIdPattern, "").trim() === "";
if (isRootBoxSelector) {
// A bare root selector styles the composition's own box (flex/grid/
// position/padding). When flattenInnerRoot preserves the authored root
// as a wrapper below `scope` (see prepareFlattenedInnerRoot), that
// wrapper is the element real children are laid out in, not `scope`
// itself, so the box styling must land there instead. It must land on
// exactly one of the two: applying it to both compounds any additive
// property (padding, margin, non-zero transform) since the wrapper
// sits nested inside the host and would inherit the effect twice.
return compositionBoxSelector(scope);
}
return selectorWithoutRootTiming.replace(compositionIdPattern, scope);
}
const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? "";
const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? "";
if (compoundAuthoredRoot) {
const authoredRootAttr = authoredRootId
? `[${AUTHORED_ROOT_ID_ATTR}="${escapeCssAttributeValue(authoredRootId)}"]`
: null;
if (authoredRootAttr && trimmed.startsWith(authoredRootAttr)) {
const rest = trimmed.slice(authoredRootAttr.length);
return `${leading}${scope}${authoredRootAttr}${rest}${trailing}`;
}
}
return `${leading}${scope} ${trimmed}${trailing}`;
}
function normalizeCompositionRootSelector(
selector: string,
scope: string,
compositionId: string,
): string {
const quotedCompId = escapeRegExp(compositionId);
const compAttr = String.raw`\[\s*data-composition-id\s*=\s*(?:"${quotedCompId}"|'${quotedCompId}')\s*\]`;
const timingAttr = String.raw`\s*\[\s*data-(?:start|duration)\s*=\s*(?:"[^"]*"|'[^']*')\s*\]`;
return selector
.replace(new RegExp(`${compAttr}(?:${timingAttr})+`, "g"), scope)
.replace(new RegExp(`(?:${timingAttr})+${compAttr}`, "g"), scope);
}
const GLOBAL_AT_RULES = new Set(["keyframes", "-webkit-keyframes", "font-face"]);
function isAtRuleNode(node: Node["parent"]): node is AtRule {
return node?.type === "atrule";
}
function isInsideGlobalAtRule(rule: Rule): boolean {
let current: Node["parent"] = rule.parent;
while (current) {
if (isAtRuleNode(current) && GLOBAL_AT_RULES.has(current.name.toLowerCase())) {
return true;
}
current = current.parent;
}
return false;
}
export function scopeCssToComposition(
css: string,
compositionId: string,
scopeSelectorOverride?: string,
authoredRootId?: string | null,
options?: { compoundAuthoredRoot?: boolean; scopeRootSelectors?: boolean },
): string {
const trimmedCompositionId = compositionId.trim();
if (!css || !trimmedCompositionId) return css;
const scope =
scopeSelectorOverride ||
`[data-composition-id="${escapeCssAttributeValue(trimmedCompositionId)}"]`;
const root = postcss.parse(css);
root.walkRules((rule) => {
if (isInsideGlobalAtRule(rule)) return;
rule.selectors = rule.selectors.map((selector) =>
scopeSelector(
selector,
scope,
trimmedCompositionId,
authoredRootId,
options?.compoundAuthoredRoot,
options?.scopeRootSelectors,
),
);
});
return root.toResult({ map: false }).css;
}
export function wrapScopedCompositionScript(
source: string,
compositionId: string,
errorLabel = "[HyperFrames] composition script error:",
scopeSelectorOverride?: string,
timelineCompositionId = compositionId,
authoredRootId?: string | null,
): string {
const compositionIdLiteral = JSON.stringify(compositionId);
const timelineCompositionIdLiteral = JSON.stringify(timelineCompositionId);
const errorLabelLiteral = JSON.stringify(errorLabel);
const escapedCompositionId = escapeRegExp(compositionId);
const authoredRootIdLiteral = JSON.stringify(authoredRootId?.trim() || null);
const scopeSelectorLiteral = JSON.stringify(scopeSelectorOverride ?? null);
const rootSelectorPatternLiteral = JSON.stringify(
String.raw`\[\s*data-composition-id\s*=\s*(?:"${escapedCompositionId}"|'${escapedCompositionId}')\s*\]`,
);
const timingSelectorPatternLiteral = JSON.stringify(
String.raw`\s*\[\s*data-(?:start|duration)\s*=\s*(?:"[^"]*"|'[^']*')\s*\]`,
);
const authoredRootIdFormsLiteral = JSON.stringify(
getAuthoredRootIdSelectorForms(authoredRootId?.trim() || ""),
);
return `(function(){
var __hfCompId = ${compositionIdLiteral};
var __hfTimelineCompId = ${timelineCompositionIdLiteral};
var __hfErrorLabel = ${errorLabelLiteral};
var __hfAuthoredRootId = ${authoredRootIdLiteral};
var __hfAuthoredRootAttr = ${JSON.stringify(AUTHORED_ROOT_ID_ATTR)};
var __hfEscapeAttr = function(value) {
return (value + "").replace(/\\\\/g, "\\\\\\\\").replace(/"/g, "\\\\\\"");
};
var __hfRootSelector = ${scopeSelectorLiteral} || (__hfCompId
? '[data-composition-id="' + __hfEscapeAttr(__hfCompId) + '"]'
: "");
var __hfRoot = null;
var __hfRootSelectorPattern = ${rootSelectorPatternLiteral};
var __hfTimingSelectorPattern = ${timingSelectorPatternLiteral};
var __hfAuthoredRootIdForms = ${authoredRootIdFormsLiteral};
var __hfAuthoredRootSelector = __hfAuthoredRootId
? "[" + __hfAuthoredRootAttr + '="' + __hfEscapeAttr(__hfAuthoredRootId) + '"]'
: "";
var __hfIsSelectorNameChar = function(char) {
return !!char && /[\\w-]/.test(char);
};
var __hfReplaceAuthoredRootIdSelectors = function(selector) {
if (!__hfAuthoredRootSelector || !__hfAuthoredRootIdForms.length || typeof selector !== "string") {
return selector;
}
var result = "";
var bracketDepth = 0;
var quote = null;
for (var index = 0; index < selector.length; index += 1) {
var char = selector[index];
var previousChar = index > 0 ? selector[index - 1] : "";
if (quote) {
result += char;
if (char === quote && previousChar !== "\\\\") {
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
result += char;
continue;
}
if (char === "[") {
bracketDepth += 1;
result += char;
continue;
}
if (char === "]") {
bracketDepth = Math.max(0, bracketDepth - 1);
result += char;
continue;
}
if (char === "#" && bracketDepth === 0) {
var matchedForm = null;
for (var formIndex = 0; formIndex < __hfAuthoredRootIdForms.length; formIndex += 1) {
var form = __hfAuthoredRootIdForms[formIndex];
if (selector.slice(index + 1, index + 1 + form.length) === form) {
matchedForm = form;
break;
}
}
if (matchedForm) {
var nextChar = selector[index + 1 + matchedForm.length];
if (!__hfIsSelectorNameChar(nextChar)) {
result += __hfAuthoredRootSelector;
index += matchedForm.length;
continue;
}
}
}
result += char;
}
return result;
};
var __hfNormalizeSelector = function(selector) {
if (!__hfCompId || typeof selector !== "string") return selector;
var normalized = selector
.replace(new RegExp(__hfRootSelectorPattern + '(?:' + __hfTimingSelectorPattern + ')+', 'g'), __hfRootSelector)
.replace(new RegExp('(?:' + __hfTimingSelectorPattern + ')+' + __hfRootSelectorPattern, 'g'), __hfRootSelector);
if (__hfAuthoredRootSelector) {
normalized = __hfReplaceAuthoredRootIdSelectors(normalized);
}
return normalized;
};
var __hfFindRoot = function() {
if (!__hfRoot && __hfRootSelector) {
__hfRoot = window.document.querySelector(__hfRootSelector);
}
return __hfRoot;
};
var __hfContains = function(node) {
var root = __hfFindRoot();
return !root || node === root || root.contains(node);
};
var __hfQueryAll = function(selector) {
var root = __hfFindRoot();
if (!root || typeof selector !== "string") {
return window.document.querySelectorAll(selector);
}
return Array.prototype.filter.call(window.document.querySelectorAll(__hfNormalizeSelector(selector)), function(node) {
return __hfContains(node);
});
};
var __hfQueryOne = function(selector) {
var matches = __hfQueryAll(selector);
return matches[0] || null;
};
var __hfGetElementById = function(id) {
var found = window.document.getElementById(id);
if (found && __hfContains(found)) return found;
var root = __hfFindRoot();
if (!root) return found || null;
var idValue = id + "";
if (__hfAuthoredRootId && __hfAuthoredRootId === idValue && root.getAttribute && root.getAttribute(__hfAuthoredRootAttr) === idValue) {
return root;
}
if (root.id === idValue) return root;
if (typeof root.querySelector !== "function") return null;
try {
var authoredRootMatch = root.querySelector('[' + __hfAuthoredRootAttr + '="' + __hfEscapeAttr(idValue) + '"]');
if (authoredRootMatch) return authoredRootMatch;
} catch {}
if (typeof CSS !== "undefined" && CSS && typeof CSS.escape === "function") {
try {
return root.querySelector("#" + CSS.escape(idValue)) || null;
} catch {}
}
try {
return root.querySelector('[id="' + __hfEscapeAttr(idValue) + '"]') || null;
} catch {}
return null;
};
var __hfScopedDocument = typeof Proxy === "function"
? new Proxy(window.document, {
get: function(target, prop, receiver) {
if (prop === "querySelector") return __hfQueryOne;
if (prop === "querySelectorAll") return __hfQueryAll;
if (prop === "getElementById") return __hfGetElementById;
var value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
})
: window.document;
var __hfTimelineRegistryProxy = null;
var __hfGetTimelineRegistry = function() {
window.__timelines = window.__timelines || {};
if (!__hfCompId || __hfCompId === __hfTimelineCompId || typeof Proxy !== "function") {
return window.__timelines;
}
if (!__hfTimelineRegistryProxy) {
__hfTimelineRegistryProxy = new Proxy(window.__timelines, {
get: function(target, prop, receiver) {
return Reflect.get(target, prop === __hfCompId ? __hfTimelineCompId : prop, target);
},
set: function(target, prop, value, receiver) {
return Reflect.set(target, prop === __hfCompId ? __hfTimelineCompId : prop, value, target);
},
});
}
return __hfTimelineRegistryProxy;
};
var __hfScopedWindow = typeof Proxy === "function"
? new Proxy(window, {
get: function(target, prop, receiver) {
if (prop === "__timelines") return __hfGetTimelineRegistry();
// Inside a sub-composition, __hyperframes is passed as a bare script
// param bound to the SCOPED variant (per-comp getVariables). But
// authors routinely write the documented window.__hyperframes.
// getVariables() form, which would otherwise fall through to the host
// page's base __hyperframes and return the WRONG (or empty) variables
// for this instance. Route it to the scoped variant too so both
// spellings resolve to this composition's own variables.
// (__hfScopedHyperframes is a hoisted var assigned below, before any
// sub-comp script -- the only code that reads this -- runs.)
if (prop === "__hyperframes") return __hfScopedHyperframes;
return Reflect.get(target, prop, target);
},
set: function(target, prop, value, receiver) {
if (prop === "__timelines") {
target.__timelines = value || {};
__hfTimelineRegistryProxy = null;
return true;
}
return Reflect.set(target, prop, value, target);
},
})
: window;
var __hfResolveGsapTarget = function(target) {
if (typeof target !== "string") return target;
return __hfQueryAll(target);
};
var __hfScopeTimeline = function(timeline) {
if (!timeline || timeline.__hfScopedCompositionRoot === __hfFindRoot()) return timeline;
["to", "from", "fromTo", "set"].forEach(function(method) {
var original = timeline[method];
if (typeof original !== "function") return;
timeline[method] = function(target) {
var args = Array.prototype.slice.call(arguments);
args[0] = __hfResolveGsapTarget(target);
return original.apply(timeline, args);
};
});
try {
Object.defineProperty(timeline, "__hfScopedCompositionRoot", {
value: __hfFindRoot(),
configurable: true,
});
} catch {
// Best-effort: timelines coming from user code may have a frozen target
// or a non-extensible defineProperty path. Swallow — the scoped root
// is an enrichment, not a correctness invariant for playback.
}
return timeline;
};
var __hfBaseGsap = typeof gsap === "undefined" ? window.gsap : gsap;
var __hfScopedGsap = !__hfBaseGsap || typeof Proxy !== "function"
? __hfBaseGsap
: new Proxy(__hfBaseGsap, {
get: function(target, prop, receiver) {
if (prop === "timeline") {
return function() {
return __hfScopeTimeline(target.timeline.apply(target, arguments));
};
}
if (prop === "to" || prop === "from" || prop === "fromTo" || prop === "set") {
return function(firstArg) {
var args = Array.prototype.slice.call(arguments);
args[0] = __hfResolveGsapTarget(firstArg);
return target[prop].apply(target, args);
};
}
if (prop === "utils" && target.utils && typeof Proxy === "function") {
return new Proxy(target.utils, {
get: function(utilsTarget, utilsProp, utilsReceiver) {
if (utilsProp === "toArray") {
return function(firstArg) {
var args = Array.prototype.slice.call(arguments);
args[0] = __hfResolveGsapTarget(firstArg);
return utilsTarget.toArray.apply(utilsTarget, args);
};
}
if (utilsProp === "selector") {
return function(base) {
var baseEl = typeof base === "string" ? __hfQueryOne(base) : base;
var root = baseEl || __hfFindRoot();
return function(selector) {
if (!root || typeof selector !== "string") return [];
return Array.prototype.filter.call(
window.document.querySelectorAll(__hfNormalizeSelector(selector)),
function(node) {
return node === root || (typeof root.contains === "function" && root.contains(node));
},
);
};
};
}
var value = Reflect.get(utilsTarget, utilsProp, utilsTarget);
return typeof value === "function" ? value.bind(utilsTarget) : value;
},
});
}
var value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
var __hfBaseHyperframes = window.__hyperframes;
var __hfScopedHyperframes = !__hfBaseHyperframes
? __hfBaseHyperframes
: Object.assign({}, __hfBaseHyperframes, {
getVariables: function() {
var byComp = window.__hfVariablesByComp;
var scoped = byComp && __hfTimelineCompId ? byComp[__hfTimelineCompId] : null;
return scoped ? Object.assign({}, scoped) : {};
},
});
var __hfRun = function() {
try {
(function(document, gsap, window, __hyperframes) {
${source.replace(/<\/(script)/gi, "<\\/$1")}
}).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow, __hfScopedHyperframes);
} catch (_err) {
console.error(__hfErrorLabel, __hfCompId, _err);
}
};
__hfFindRoot();
__hfRun();
})();`;
}
export function wrapInlineScriptWithErrorBoundary(source: string, errorLabel: string): string {
return `(function(){ try { Function(${JSON.stringify(source)}).call(window); } catch (_err) { console.error(${JSON.stringify(errorLabel)}, _err); } })();`;
}
/**
* Build the statement that populates `window.__hfVariablesByComp` — the table
* the scoped `getVariables` above reads. Returns `null` when there are no
* per-instance values.
*
* The WRITER lives next to the READER (the scoped `getVariables` in
* `wrapScopedCompositionScript`) on purpose: every compile path that wraps the
* reader MUST also emit this writer before the sub-comp scripts run. The
* render compiler (`htmlCompiler`) inlined the reader scripts but never emitted
* the writer while the preview bundler (`htmlBundler`) did, so
* `getVariables()` returned `{}` only during render — parametrized sub-comps
* silently shipped blank/default text in the final MP4 while snapshot QA passed
* (issue #2064). Both callers now share this one builder so they can't drift.
*/
export function buildVariablesByCompScript(
variablesByComp: Record<string, Record<string, unknown>>,
): string | null {
if (!variablesByComp || Object.keys(variablesByComp).length === 0) return null;
return `window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${JSON.stringify(variablesByComp)});`;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { compileHtml } from "./htmlCompiler.js";
describe("compileHtml", () => {
it("preserves explicit looped media durations that exceed source duration", async () => {
const html =
'<video id="hero" src="hero.webm" data-start="0" data-duration="4" data-end="4" loop>';
const compiled = await compileHtml(html, "/project", async () => 3.125);
expect(compiled).toContain('data-duration="4"');
expect(compiled).toContain('data-end="4"');
});
it("still clamps non-looping media durations to source duration", async () => {
const html = '<video id="hero" src="hero.webm" data-start="0" data-duration="4" data-end="4">';
const compiled = await compileHtml(html, "/project", async () => 3.125);
expect(compiled).toContain('data-duration="3.125"');
expect(compiled).toContain('data-end="3.125"');
});
it("preserves explicit media durations when probe precision differs slightly", async () => {
const html =
'<audio id="click" src="click.mp3" data-start="0" data-duration="1.044898" data-end="1.044898">';
const compiled = await compileHtml(html, "/project", async () => 1);
expect(compiled).toContain('data-duration="1.044898"');
expect(compiled).toContain('data-end="1.044898"');
});
});
@@ -0,0 +1,90 @@
import { resolve } from "path";
import {
compileTimingAttrs,
injectDurations,
extractResolvedMedia,
clampDurations,
shouldClampMediaDuration,
type ResolvedDuration,
} from "./timingCompiler";
/**
* Callback to probe media duration. If not provided, media duration resolution is skipped.
* Return duration in seconds, or 0 if unknown.
*/
export type MediaDurationProber = (src: string) => Promise<number>;
function resolveMediaSrc(src: string, projectDir: string): string {
return src.startsWith("http://") || src.startsWith("https://") ? src : resolve(projectDir, src);
}
/**
* Compile HTML with full duration resolution.
*
* 1. Static pass: compileTimingAttrs() adds data-end where data-duration exists
* 2. For unresolved video/audio (no data-duration): probe via probeMediaDuration, inject durations
* 3. For pre-resolved video/audio: validate data-duration against actual source, clamp if needed
*
* @param rawHtml - The raw HTML string
* @param projectDir - The project directory for resolving relative paths
* @param probeMediaDuration - Optional callback to probe media duration (e.g., via ffprobe)
*/
export async function compileHtml(
rawHtml: string,
projectDir: string,
probeMediaDuration?: MediaDurationProber,
): Promise<string> {
const { html: staticCompiled, unresolved } = compileTimingAttrs(rawHtml);
let html = staticCompiled;
if (!probeMediaDuration) return html;
// Phase 1: Resolve missing durations
const mediaUnresolved = unresolved.filter(
(el) => el.tagName === "video" || el.tagName === "audio",
);
if (mediaUnresolved.length > 0) {
const resolutions: ResolvedDuration[] = [];
for (const el of mediaUnresolved) {
if (!el.src) continue;
const src = resolveMediaSrc(el.src, projectDir);
const fileDuration = await probeMediaDuration(src);
if (fileDuration <= 0) continue;
const effectiveDuration = fileDuration - el.mediaStart;
resolutions.push({
id: el.id,
duration: effectiveDuration > 0 ? effectiveDuration : fileDuration,
});
}
if (resolutions.length > 0) {
html = injectDurations(html, resolutions);
}
}
// Phase 2: Validate pre-resolved media — clamp data-duration to actual source duration
const preResolved = extractResolvedMedia(html);
const clampList: ResolvedDuration[] = [];
for (const el of preResolved) {
if (!el.src) continue;
if (el.loop) continue;
const src = resolveMediaSrc(el.src, projectDir);
const fileDuration = await probeMediaDuration(src);
if (fileDuration <= 0) continue;
const maxDuration = fileDuration - el.mediaStart;
if (maxDuration > 0 && shouldClampMediaDuration(el.duration, maxDuration)) {
clampList.push({ id: el.id, duration: maxDuration });
}
}
if (clampList.length > 0) {
html = clampDurations(html, clampList);
}
return html;
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
injectScriptsAtHeadStart,
injectScriptsIntoHtml,
parseHTMLContent,
stripEmbeddedRuntimeScripts,
} from "./htmlDocument.js";
describe("htmlDocument helpers", () => {
it("wraps fragments before parsing", () => {
const doc = parseHTMLContent("<template><span>hello</span></template>");
expect(doc.body.querySelector("template")?.innerHTML).toContain("<span>hello</span>");
});
it("strips every known embedded HyperFrames runtime marker", () => {
const html = `
<script src="hyperframe.runtime.iife.js"></script>
<script src="hyperframes-runtime.modular.inline.js"></script >
<script src="hyperframe-runtime.modular-runtime.inline.js"></script>
<script data-hyperframes-preview-runtime="1"></script>
<script>window.__playerReady = true;</script >
<script>window.__renderReady = false;</script>
<script>window.authored = true;</script>`;
const stripped = stripEmbeddedRuntimeScripts(html);
expect(stripped).not.toContain("hyperframe.runtime.iife.js");
expect(stripped).not.toContain("hyperframes-runtime.modular.inline.js");
expect(stripped).not.toContain("hyperframe-runtime.modular-runtime.inline.js");
expect(stripped).not.toContain("data-hyperframes-preview-runtime");
expect(stripped).not.toContain("window.__playerReady");
expect(stripped).not.toContain("window.__renderReady");
expect(stripped).toContain("window.authored = true");
});
it("keeps authored scripts that reference runtime readiness flags", () => {
const html = `
<script>
window.__timelines = window.__timelines || {};
if (window.__renderReady) window.authoredReadySeen = true;
window.__timelines["main"] = {};
</script>`;
const stripped = stripEmbeddedRuntimeScripts(html);
expect(stripped).toContain('window.__timelines["main"]');
expect(stripped).toContain("window.__renderReady");
});
it("does not treat non-script tags as scripts when stripping runtimes", () => {
const html = "<scripture>window.__playerReady = true;</scripture>";
expect(stripEmbeddedRuntimeScripts(html)).toBe(html);
});
it("injects head and body scripts without replacement-token interpolation", () => {
const html = "<html><head></head><body></body></html>";
const injected = injectScriptsIntoHtml(html, ["window.x = '$&';"], ["window.y = '$&';"]);
expect(injected).toContain("<script>window.x = '$&';</script>\n</head>");
expect(injected).toContain("<script>window.y = '$&';</script>\n</body>");
});
it("injects early head scripts before authored head scripts", () => {
const html = '<html><head><script id="authored"></script></head><body></body></html>';
const injected = injectScriptsAtHeadStart(html, ["window.early = true;"]);
expect(injected.indexOf("window.early = true")).toBeLessThan(injected.indexOf('id="authored"'));
});
it("escapes inline scripts so authored script text cannot break out of the wrapper tag", () => {
const html = "<html><head></head><body></body></html>";
const injected = injectScriptsIntoHtml(
html,
['window.payload = "</script ><script>window.pwned = true;</script>";'],
["window.comment = '<!-- kept as script text';"],
);
expect(injected).toContain("<\\/script ><script>window.pwned = true;<\\/script>");
expect(injected).toContain("<\\!-- kept as script text");
expect(injected).not.toContain("</script ><script>window.pwned = true;");
});
});
+221
View File
@@ -0,0 +1,221 @@
import { parseHTML } from "linkedom";
export const RUNTIME_BOOTSTRAP_ATTR = "data-hyperframes-preview-runtime";
const RUNTIME_SRC_MARKERS = [
"hyperframe.runtime.iife.js",
"hyperframes-runtime.modular.inline.js",
"hyperframe-runtime.modular-runtime.inline.js",
RUNTIME_BOOTSTRAP_ATTR,
];
const RUNTIME_INLINE_MARKERS = [
"__hyperframeRuntimeBootstrapped",
"__hyperframeRuntime",
"__hyperframeRuntimeTeardown",
"__HF_EXPORT_RENDER_SEEK_CONFIG",
"window.__player =",
];
const SIMPLE_RUNTIME_FLAG_ASSIGNMENTS = [
/^window\.__playerReady\s*=\s*(?:true|false)\s*;?$/,
/^window\.__renderReady\s*=\s*(?:true|false)\s*;?$/,
];
/**
* Parse a full HTML document or wrap a fragment so linkedom consistently puts
* fragment content under document.body.
*/
export function parseHTMLContent(html: string): Document {
const trimmed = html.trimStart().toLowerCase();
if (trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")) {
return parseHTML(html).document;
}
return parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`).document;
}
export function stripEmbeddedRuntimeScripts(html: string): string {
if (!html) return html;
const loweredHtml = html.toLowerCase();
let output = "";
let cursor = 0;
while (cursor < html.length) {
const scriptStart = findScriptStart(loweredHtml, cursor);
if (scriptStart === -1) {
output += html.slice(cursor);
break;
}
output += html.slice(cursor, scriptStart);
const startTagEnd = findTagEnd(html, scriptStart + 1);
if (startTagEnd === -1) {
output += html.slice(scriptStart);
break;
}
const closeTagEnd = findScriptCloseTagEnd(loweredHtml, startTagEnd + 1);
const scriptEnd = closeTagEnd === -1 ? html.length : closeTagEnd;
const block = html.slice(scriptStart, scriptEnd);
if (!shouldStripRuntimeScriptBlock(block)) {
output += block;
}
cursor = scriptEnd;
}
return output;
}
function findScriptStart(loweredHtml: string, from: number): number {
let index = loweredHtml.indexOf("<script", from);
while (index !== -1) {
const next = loweredHtml[index + "<script".length] ?? "";
if (isTagBoundary(next)) return index;
index = loweredHtml.indexOf("<script", index + 1);
}
return -1;
}
function findTagEnd(html: string, from: number): number {
let quote: string | undefined;
for (let index = from; index < html.length; index += 1) {
const char = html[index];
if (quote) {
if (char === quote) quote = undefined;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === ">") return index;
}
return -1;
}
function findScriptCloseTagEnd(loweredHtml: string, from: number): number {
let index = loweredHtml.indexOf("</script", from);
while (index !== -1) {
const closeTagEnd = findScriptCloseTagBoundary(loweredHtml, index + "</script".length);
if (closeTagEnd !== -1) return closeTagEnd;
index = loweredHtml.indexOf("</script", index + 1);
}
return -1;
}
function findScriptCloseTagBoundary(loweredHtml: string, from: number): number {
let cursor = from;
while (cursor < loweredHtml.length && isHtmlWhitespace(loweredHtml[cursor] ?? "")) {
cursor += 1;
}
return loweredHtml[cursor] === ">" ? cursor + 1 : -1;
}
function shouldStripRuntimeScriptBlock(block: string): boolean {
const lowered = block.toLowerCase();
for (const marker of RUNTIME_SRC_MARKERS) {
if (lowered.includes(marker.toLowerCase())) return true;
}
for (const marker of RUNTIME_INLINE_MARKERS) {
if (block.includes(marker)) return true;
}
const scriptSource = getScriptSource(block).trim();
for (const pattern of SIMPLE_RUNTIME_FLAG_ASSIGNMENTS) {
if (pattern.test(scriptSource)) return true;
}
return false;
}
function getScriptSource(block: string): string {
const startTagEnd = findTagEnd(block, 1);
if (startTagEnd === -1) return "";
const loweredBlock = block.toLowerCase();
const closeTagStart = loweredBlock.lastIndexOf("</script");
const end = closeTagStart === -1 ? block.length : closeTagStart;
return block.slice(startTagEnd + 1, end);
}
function isTagBoundary(char: string): boolean {
return char === "" || char === ">" || char === "/" || isHtmlWhitespace(char);
}
function isHtmlWhitespace(char: string): boolean {
return char === " " || char === "\n" || char === "\t" || char === "\r" || char === "\f";
}
function escapeInlineScriptSource(source: string): string {
return escapeCaseInsensitiveToken(
escapeCaseInsensitiveToken(source, "</script", "<\\/script"),
"<!--",
"<\\!--",
);
}
function escapeCaseInsensitiveToken(source: string, token: string, replacement: string): string {
const loweredSource = source.toLowerCase();
const loweredToken = token.toLowerCase();
let output = "";
let cursor = 0;
while (cursor < source.length) {
const tokenStart = loweredSource.indexOf(loweredToken, cursor);
if (tokenStart === -1) {
output += source.slice(cursor);
break;
}
output += source.slice(cursor, tokenStart) + replacement;
cursor = tokenStart + token.length;
}
return output;
}
function inlineScriptTags(scripts: readonly string[]): string {
return scripts.map((source) => `<script>${escapeInlineScriptSource(source)}</script>`).join("\n");
}
export function injectScriptsAtHeadStart(html: string, scripts: readonly string[]): string {
if (scripts.length === 0) return html;
const headTags = inlineScriptTags(scripts);
if (html.includes("<head")) {
return html.replace(/<head\b[^>]*>/i, (match) => `${match}\n${headTags}`);
}
if (html.includes("<body")) {
return html.replace("<body", () => `${headTags}\n<body`);
}
return `${headTags}\n${html}`;
}
export function injectScriptsIntoHtml(
html: string,
headScripts: readonly string[],
bodyScripts: readonly string[],
stripEmbeddedRuntime = true,
): string {
if (stripEmbeddedRuntime) {
html = stripEmbeddedRuntimeScripts(html);
}
if (headScripts.length > 0) {
const headTags = inlineScriptTags(headScripts);
if (html.includes("</head>")) {
// Function replacement avoids `$&` interpolation in runtime source.
html = html.replace("</head>", () => `${headTags}\n</head>`);
} else if (html.includes("<body")) {
html = html.replace("<body", () => `${headTags}\n<body`);
} else {
html = `${headTags}\n${html}`;
}
}
if (bodyScripts.length > 0) {
const bodyTags = inlineScriptTags(bodyScripts);
if (html.includes("</body>")) {
html = html.replace("</body>", () => `${bodyTags}\n</body>`);
} else {
html = `${html}\n${bodyTags}`;
}
}
return html;
}
+81
View File
@@ -0,0 +1,81 @@
// Timing resolver — shared pure resolver for word-anchored elastic timing (WS-C).
// Consumed by both preview (sdk) and render (timingCompiler) paths.
export {
resolveTimings,
type WordTiming,
type ElementAnchor,
type AuthoredTiming,
type ResolvedTiming,
type ResolveTimingsInput,
type ResolveTimingsResult,
} from "./timingResolver";
// Timing compiler (browser-safe)
export {
compileTimingAttrs,
injectDurations,
extractResolvedMedia,
clampDurations,
shouldClampMediaDuration,
type UnresolvedElement,
type ResolvedDuration,
type ResolvedMediaElement,
type CompilationResult,
} from "./timingCompiler";
// HTML compiler (Node.js — requires fs)
export { compileHtml, type MediaDurationProber } from "./htmlCompiler";
// HTML bundler (Node.js — requires fs, linkedom, esbuild)
export {
assignBundledRuntimeCompositionIds,
type BundledHostCompositionIdentity,
bundleToSingleHtml,
type BundleOptions,
prepareFlattenedInnerRoot,
FLATTENED_INNER_ROOT_STRIP_ATTRS,
emitRootCompositionVariableStyles,
} from "./htmlBundler";
export { readDeclaredDefaults, parseHostVariableValues } from "../runtime/getVariables";
export {
RUNTIME_BOOTSTRAP_ATTR,
injectScriptsAtHeadStart,
injectScriptsIntoHtml,
parseHTMLContent,
stripEmbeddedRuntimeScripts,
} from "./htmlDocument";
// Static guard
export {
validateHyperframeHtmlContract,
type HyperframeStaticFailureReason,
type HyperframeStaticGuardResult,
} from "./staticGuard";
// Composition isolation helpers
export {
buildVariablesByCompScript,
scopeCssToComposition,
wrapScopedCompositionScript,
} from "./compositionScoping";
// Sub-composition inlining (shared between bundler and producer)
export {
inlineSubCompositions,
type InlineSubCompositionsOptions,
type InlineSubCompositionsResult,
} from "./inlineSubCompositions";
// Sub-composition usability check (shared between the inliner, lint, and the
// render pre-flight abort) — single source of truth for "is this
// data-composition-src file usable?"
export {
checkSubCompositionUsability,
type ParsableDocumentLike,
type SubCompositionValidity,
type SubCompositionValidityReason,
} from "./subCompositionValidity";
// Asset-path primitives (shared across core, producer, CLI)
export { CSS_URL_RE, PATH_ATTRS, isNonRelativeUrl, isPathInside } from "./assetPaths";
@@ -0,0 +1,404 @@
import { describe, expect, it } from "vitest";
import { parseHTML } from "linkedom";
import { inlineSubCompositions } from "./inlineSubCompositions";
import { readDeclaredDefaults, parseHostVariableValues } from "../runtime/getVariables";
// Fixtures reference GSAP CDN but are never loaded in a real browser — resolveHtml is mocked.
/**
* Minimal sub-composition HTML that uses `#intro` as its CSS and GSAP scope.
* This is the pattern that breaks when the producer path strips the inner root.
*/
const SUB_COMP_HTML = `<template id="intro-template">
<div id="intro" data-composition-id="intro" data-width="1920" data-height="1080">
<div class="title" style="opacity:0;">HELLO WORLD</div>
<style>
#intro { position:relative; width:1920px; height:1080px; background:#111; }
#intro .title { font-size:120px; color:#fff; }
</style>
<script>
(function() {
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
tl.fromTo('#intro .title', { opacity:0 }, { opacity:1, duration:0.5 }, 0.2);
window.__timelines['intro'] = tl;
})();
</script>
</div>
</template>`;
function makeHostDocument(compId: string) {
const { document } = parseHTML(`<!DOCTYPE html>
<html><body>
<div data-composition-id="main">
<div data-composition-id="${compId}" data-composition-src="intro.html"
data-start="0" data-duration="4" data-track-index="0"></div>
</div>
</body></html>`);
return document;
}
describe("inlineSubCompositions #ID selector scoping divergence", () => {
it.each([
{ label: "empty", html: "" },
{ label: "whitespace-only", html: " \n \t " },
{
label: "valid-parse-empty-body",
html: "<!doctype html><html><head></head><body></body></html>",
},
// linkedom's parseHTML("just some text") returns documentElement === null.
// Any code that then touches .head/.body (as linkedom's own internals do)
// throws "Cannot destructure property 'firstElementChild' of
// 'documentElement' as it is null" — the #1 raw crash in production
// telemetry. Must be skipped gracefully, not crash.
{ label: "malformed non-HTML text", html: "just some plain text, no tags at all" },
])("skips $label sub-composition files gracefully", ({ html }) => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const missing: string[] = [];
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => html,
parseHtml: (h) => parseHTML(h).document,
onMissingComposition: (src) => missing.push(src),
});
expect(missing).toEqual(["intro.html"]);
expect(result.styles).toHaveLength(0);
expect(result.scripts).toHaveLength(0);
});
it("passes the failure reason through to onMissingComposition", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const reasons: Array<string | undefined> = [];
inlineSubCompositions(document, [host], {
resolveHtml: () => "",
parseHtml: (h) => parseHTML(h).document,
onMissingComposition: (_src, reason) => reasons.push(reason),
});
expect(reasons).toHaveLength(1);
expect(reasons[0]).toContain("empty");
});
it("producer path (no flattenInnerRoot): strips inner root, losing #id attribute", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The producer path takes innerHTML when compId matches, stripping the
// wrapper <div id="intro" ...>. The host element should NOT contain a
// child with id="intro" — the id attribute is lost.
const innerRootById = host.querySelector("#intro");
expect(innerRootById).toBeNull();
// The host itself still has data-composition-id="intro" (from the
// original markup), but no element inside has id="intro".
expect(host.getAttribute("data-composition-id")).toBe("intro");
// CSS was scoped: #intro selectors should be rewritten to use
// data-hf-authored-id attribute selector so they still resolve.
const scopedCss = result.styles.join("\n");
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
expect(scopedCss).not.toContain("#intro");
});
it("producer path: scoped CSS rewrites #id selectors to [data-hf-authored-id] attribute", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The CSS scoper rewrites `#intro` to `[data-hf-authored-id="intro"]`
// so that the selector resolves against the flattened structure.
const scopedCss = result.styles.join("\n");
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
expect(scopedCss).toContain('[data-hf-authored-id="intro"] .title');
});
it("producer path: scoped scripts rewrite #intro selectors for GSAP targets", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The wrapped script should contain the authored root id normalization
// logic so that runtime querySelector('#intro .title') maps to the
// data-hf-authored-id attribute selector.
const wrappedScript = result.scripts.join("\n");
expect(wrappedScript).toContain("__hfAuthoredRootId");
expect(wrappedScript).toContain('"intro"');
});
it("bundler path (with flattenInnerRoot): preserves inner root as a child element", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
// Simulate the bundler's flattenInnerRoot: clone the element, add
// data-hf-authored-id, strip timing attrs (simplified here).
function flattenInnerRoot(innerRoot: Element): Element {
const clone = innerRoot.cloneNode(true) as Element;
const authoredId = clone.getAttribute("id");
if (authoredId) {
clone.setAttribute("data-hf-authored-id", authoredId);
clone.removeAttribute("id");
}
clone.removeAttribute("data-start");
clone.removeAttribute("data-duration");
return clone;
}
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
flattenInnerRoot,
});
// With flattenInnerRoot, the inner root is preserved as a child of the
// host via outerHTML. The data-hf-authored-id attribute is present.
const authoredRoot = host.querySelector('[data-hf-authored-id="intro"]');
expect(authoredRoot).not.toBeNull();
// CSS is still rewritten to use the attribute selector.
const scopedCss = result.styles.join("\n");
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
});
it("with flattenInnerRoot: restores data-composition-id on the wrapper for an anonymous host", () => {
// Regression test: a host mounted via data-composition-src with no
// data-composition-id of its own (an "anonymous" host). The composition
// styles its own root box via the bare composition-id selector and a
// script self-references it too — both need something in the render DOM
// to actually carry that id once flattenInnerRoot strips it from the
// wrapper by default.
const { document } = parseHTML(`<!DOCTYPE html>
<html><body>
<div data-composition-id="main">
<div data-composition-src="scoped-text.html" data-start="0" data-duration="3"></div>
</div>
</body></html>`);
const host = document.querySelector('[data-composition-src="scoped-text.html"]')!;
const scopedTextHtml = `<template id="scoped-text-template">
<div data-composition-id="scoped-text" data-width="1080" data-height="1920" data-duration="3">
<div class="label">Scoped Text Should Stay Styled</div>
<style>
[data-composition-id="scoped-text"] { display: flex; background: rgb(12, 12, 12); }
</style>
</div>
</template>`;
function flattenInnerRoot(innerRoot: Element): Element {
const clone = innerRoot.cloneNode(true) as Element;
clone.removeAttribute("data-composition-id");
clone.removeAttribute("data-start");
clone.removeAttribute("data-duration");
clone.setAttribute("data-hf-inner-root", "true");
return clone;
}
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => scopedTextHtml,
parseHtml: (html) => parseHTML(html).document,
flattenInnerRoot,
});
const wrapper = host.querySelector("[data-hf-inner-root]");
expect(wrapper?.getAttribute("data-composition-id")).toBe("scoped-text");
const scopedCss = result.styles.join("\n");
expect(scopedCss).toContain("display: flex");
});
it("extracts <link> elements from sub-composition <head> with original rel and crossorigin", () => {
const subCompWithLinks = `<!doctype html>
<html><head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@800&display=swap">
</head><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<span>Hello</span>
</div>
</body></html>`;
const document = makeHostDocument("captions");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => subCompWithLinks,
parseHtml: (html) => parseHTML(html).document,
});
expect(result.externalLinks).toHaveLength(3);
expect(result.externalLinks[0]).toEqual({
href: "https://fonts.googleapis.com",
rel: "preconnect",
crossorigin: undefined,
});
expect(result.externalLinks[1]).toEqual({
href: "https://fonts.gstatic.com",
rel: "preconnect",
crossorigin: "",
});
expect(result.externalLinks[2]).toEqual({
href: "https://fonts.googleapis.com/css2?family=Montserrat:wght@800&display=swap",
rel: "stylesheet",
crossorigin: undefined,
});
});
it("deduplicates link hrefs across multiple sub-compositions", () => {
const subComp = `<!doctype html>
<html><head>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@800">
</head><body>
<div data-composition-id="cap1" data-width="1920" data-height="1080"><span>A</span></div>
</body></html>`;
const { document } = parseHTML(`<!DOCTYPE html>
<html><body>
<div data-composition-id="main">
<div data-composition-id="cap1" data-composition-src="cap1.html" data-start="0" data-duration="4" data-track-index="0"></div>
<div data-composition-id="cap2" data-composition-src="cap2.html" data-start="4" data-duration="4" data-track-index="1"></div>
</div>
</body></html>`);
const hosts = Array.from(document.querySelectorAll("[data-composition-src]"));
const result = inlineSubCompositions(document, hosts, {
resolveHtml: () => subComp,
parseHtml: (html) => parseHTML(html).document,
});
expect(result.externalLinks).toHaveLength(1);
expect(result.externalLinks[0]!.href).toBe(
"https://fonts.googleapis.com/css2?family=Montserrat:wght@800",
);
});
it("propagates data-timeline-locked from inner root to host element", () => {
const lockedSubComp = `<!doctype html>
<html><head></head><body>
<div id="captions" data-composition-id="captions" data-timeline-locked data-width="1920" data-height="1080">
<span>Hello</span>
</div>
</body></html>`;
const document = makeHostDocument("captions");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
inlineSubCompositions(document, [host], {
resolveHtml: () => lockedSubComp,
parseHtml: (html) => parseHTML(html).document,
});
expect(host.hasAttribute("data-timeline-locked")).toBe(true);
});
it("producer path propagates data-hf-authored-id to host when inner root has id", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The inner root's id="intro" is stripped (innerHTML), but the producer
// now propagates it as data-hf-authored-id on the host element so that
// rewritten #ID selectors ([data-hf-authored-id="intro"]) resolve.
expect(host.getAttribute("data-hf-authored-id")).toBe("intro");
// The original #intro element is still gone — innerHTML stripped it.
const introById = host.querySelector("#intro");
expect(introById).toBeNull();
expect(host.getAttribute("data-composition-id")).toBe("intro");
});
it("producer path: scoped CSS matches host element when both attributes coexist", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
compoundAuthoredRoot: true,
});
// After inlining, the host has both data-composition-id and data-hf-authored-id.
// CSS selectors targeting the root must be compound (no space) so they match
// when both attributes are on the same element.
expect(host.getAttribute("data-composition-id")).toBe("intro");
expect(host.getAttribute("data-hf-authored-id")).toBe("intro");
const scopedCss = result.styles.join("\n");
// Root-only selector: must be compound
expect(scopedCss).toMatch(/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]/);
// Must NOT have a descendant combinator between the two attribute selectors
expect(scopedCss).not.toMatch(
/\[data-composition-id="intro"\]\s+\[data-hf-authored-id="intro"\]\s*\{/,
);
// Descendant selector: compound root + space + child
expect(scopedCss).toMatch(
/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]\s+\.title/,
);
});
});
describe("inlineSubCompositions variable defaults on a template sub-comp root div", () => {
const SUB_COMP_WITH_VAR = `<template id="card-template">
<div id="card" data-composition-id="card" data-width="1920" data-height="1080"
data-composition-variables='[{"id":"headline","type":"string","label":"Headline","default":"Hi there"}]'>
<h1 class="title" data-var-text="headline">Hi there</h1>
</div>
</template>`;
function hostDoc() {
const { document } = parseHTML(`<!DOCTYPE html><html><body>
<div data-composition-id="main">
<div data-composition-id="card" data-composition-src="card.html"
data-start="0" data-duration="4" data-track-index="0"></div>
</div></body></html>`);
return document;
}
it("aggregates defaults declared on the inner root div (template comps have no <html> to hold them)", () => {
const document = hostDoc();
const host = document.querySelector('[data-composition-src="card.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_WITH_VAR,
parseHtml: (h) => parseHTML(h).document,
readVariableDefaults: readDeclaredDefaults,
parseHostVariables: parseHostVariableValues,
});
expect(result.variablesByComp["card"]).toMatchObject({ headline: "Hi there" });
});
it("lets a per-instance host value override the declared default", () => {
const document = hostDoc();
const host = document.querySelector('[data-composition-src="card.html"]')!;
host.setAttribute("data-variable-values", JSON.stringify({ headline: "Overridden" }));
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_WITH_VAR,
parseHtml: (h) => parseHTML(h).document,
readVariableDefaults: readDeclaredDefaults,
parseHostVariables: parseHostVariableValues,
});
expect(result.variablesByComp["card"]).toMatchObject({ headline: "Overridden" });
});
});
@@ -0,0 +1,413 @@
/**
* Shared sub-composition inlining logic.
*
* Both the core bundler (preview) and the producer compiler (render) need to
* inline sub-composition HTML referenced via `data-composition-src`. This
* module is the single source of truth for that transformation, eliminating
* divergence that previously caused bugs (e.g. producer not setting
* `data-composition-file`).
*/
import {
rewriteAssetPaths,
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "./rewriteSubCompPaths";
import { queryByAttr } from "../utils/cssSelector";
import {
scopeCssToComposition,
wrapInlineScriptWithErrorBoundary,
wrapScopedCompositionScript,
} from "./compositionScoping";
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
// ---------------------------------------------------------------------------
// Public interface
// ---------------------------------------------------------------------------
export interface InlineSubCompositionsOptions {
/**
* Resolve the HTML content for a sub-composition given its `data-composition-src` value.
* Return `null` when the file cannot be found.
*/
resolveHtml: (srcPath: string) => string | null;
/**
* Parse an HTML string into a Document. The returned object must expose
* standard DOM APIs (querySelector, querySelectorAll, body, head, etc.).
* Both linkedom's `parseHTML(...).document` and the core bundler's
* `parseHTMLContent(...)` satisfy this contract.
*/
parseHtml: (html: string) => Document;
/**
* Identity map produced by `assignBundledRuntimeCompositionIds`.
* When provided, authoredCompositionId and runtimeCompositionId are read
* from this map instead of from the host element's attributes directly.
* The bundler uses this; the producer can omit it.
*/
hostIdentityMap?: Map<
Element,
{ authoredCompositionId: string | null; runtimeCompositionId: string | null }
>;
/**
* When true, rewrite `url(...)` references in inline `style` attributes
* on sub-composition elements. The bundler enables this; the producer
* can skip it.
*/
rewriteInlineStyles?: boolean;
/**
* Prepare the inner root element before injecting it into the host.
* The bundler's `prepareFlattenedInnerRoot` clones the element, strips
* timing attributes, and adds `data-hf-inner-root`. When omitted, the
* inner root's outerHTML is injected as-is.
*/
flattenInnerRoot?: (innerRoot: Element) => Element;
/**
* When true, CSS selectors targeting the authored root use a compound
* selector (`[scope][root]`) instead of a descendant (`[scope] [root]`).
* Enable this in the producer path where the inner root merges onto
* the host element via innerHTML — both attributes end up on the same
* element and a descendant selector won't match.
*/
compoundAuthoredRoot?: boolean;
/**
* Read declared variable defaults from a sub-composition's `<html>` element.
* The bundler passes `readDeclaredDefaults`; the producer can omit this.
*/
readVariableDefaults?: (docElement: Element) => Record<string, unknown>;
/**
* Parse host-level variable overrides from `data-variable-values`.
* The bundler passes `parseHostVariableValues`; the producer can omit this.
*/
parseHostVariables?: (host: Element) => Record<string, unknown>;
/**
* Build a CSS attribute selector for scoping, e.g.
* `[data-composition-id="my-comp"]`. Defaults to a simple implementation
* when not provided. The bundler passes `cssAttributeSelector` which
* handles escaping.
*/
buildScopeSelector?: (compId: string) => string;
/**
* Error label prefix used in wrapped composition scripts.
* Defaults to `"[HyperFrames] composition script error:"`.
*/
scriptErrorLabel?: string;
/**
* Log a warning when a composition file cannot be resolved. `reason` is a
* short, human-readable explanation (e.g. "the file is empty (0 bytes or
* whitespace-only)") from `checkSubCompositionUsability` — present for
* every skip except when `resolveHtml` returns `null` (file not found,
* which callers detect themselves before calling `resolveHtml`).
* Defaults to `console.warn`.
*/
onMissingComposition?: (srcPath: string, reason?: string) => void;
}
export interface InlineSubCompositionsResult {
styles: string[];
scripts: string[];
externalScriptSrcs: string[];
scriptItems: Array<{ kind: "inline"; content: string } | { kind: "external"; src: string }>;
externalLinks: { href: string; rel: string; crossorigin?: string }[];
variablesByComp: Record<string, Record<string, unknown>>;
}
// ---------------------------------------------------------------------------
// Default helpers
// ---------------------------------------------------------------------------
function defaultBuildScopeSelector(compId: string): string {
const escaped = compId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `[data-composition-id="${escaped}"]`;
}
// ---------------------------------------------------------------------------
// Core implementation
// ---------------------------------------------------------------------------
/**
* Inline sub-compositions into a document. For each host element in `hosts`:
*
* 1. Resolve the sub-composition HTML via `options.resolveHtml`
* 2. Parse it, find `<template>` or `<body>` content
* 3. Find the inner `[data-composition-id]` root
* 4. Extract `<style>` elements, scope CSS, collect them
* 5. Extract `<script>` elements, wrap inline scripts, collect them
* 6. Collect external script `src` URLs for deduplication
* 7. Rewrite asset paths (and optionally inline-style asset URLs)
* 8. Copy dimension attrs from inner root to host if missing
* 9. Set `data-composition-file` on host
* 10. Remove `data-composition-src` from host
* 11. Inject the content into the host element
*/
// fallow-ignore-next-line complexity
export function inlineSubCompositions(
document: Document,
hosts: Element[],
options: InlineSubCompositionsOptions,
): InlineSubCompositionsResult {
const {
resolveHtml,
parseHtml,
hostIdentityMap,
rewriteInlineStyles = false,
flattenInnerRoot,
compoundAuthoredRoot,
readVariableDefaults,
parseHostVariables,
buildScopeSelector = defaultBuildScopeSelector,
scriptErrorLabel = "[HyperFrames] composition script error:",
onMissingComposition,
} = options;
const styles: string[] = [];
const scripts: string[] = [];
const externalScriptSrcs: string[] = [];
const scriptItems: InlineSubCompositionsResult["scriptItems"] = [];
const externalLinks: { href: string; rel: string; crossorigin?: string }[] = [];
const seenLinkHrefs = new Set<string>();
const variablesByComp: Record<string, Record<string, unknown>> = {};
for (const hostEl of hosts) {
const src = hostEl.getAttribute("data-composition-src");
if (!src) continue;
const compHtml = resolveHtml(src);
// Shared with lint + render pre-flight (@hyperframes/parsers'
// subCompositionValidity.ts) so all three callers agree on what counts
// as a usable sub-composition file. This path stays intentionally
// tolerant (skip, don't throw) — preview and studio must keep bundling
// around a scene that's still being authored. Lint and the render
// pre-flight check use the same helper to fail loudly instead.
const validity = checkSubCompositionUsability(compHtml, parseHtml);
if (!validity.ok) {
onMissingComposition?.(src, validity.detail);
continue;
}
if (compHtml == null) {
// Unreachable in practice — checkSubCompositionUsability's "empty"
// reason already covers null/undefined — but this lets TypeScript
// narrow compHtml to `string` below without an `as T` assertion.
onMissingComposition?.(src);
continue;
}
const compDoc = parseHtml(compHtml);
// Determine composition IDs
let compId: string | null;
let runtimeCompId: string;
if (hostIdentityMap) {
const identity = hostIdentityMap.get(hostEl);
compId = identity?.authoredCompositionId || null;
runtimeCompId = identity?.runtimeCompositionId || compId || "";
} else {
compId = hostEl.getAttribute("data-composition-id") || null;
runtimeCompId = compId || "";
}
// Find content: prefer <template>, fall back to <body>
const contentRoot = compDoc.querySelector("template");
const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || "";
if (!contentHtml.trim()) {
onMissingComposition?.(src);
continue;
}
const contentDoc = parseHtml(contentHtml);
if (!contentDoc.documentElement) {
onMissingComposition?.(src);
continue;
}
// Find the inner composition root
const innerRoot = compId
? queryByAttr(contentDoc, "data-composition-id", compId)
: contentDoc.querySelector("[data-composition-id]");
const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || "";
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
const scopeCompId = compId || inferredCompId;
const runtimeScope = runtimeCompId ? buildScopeSelector(runtimeCompId) : "";
// Variable merging (bundler feature). Read declared defaults from the
// document element (full-document sub-comps) AND the inner composition root
// (template/fragment sub-comps store their schema on the root div, not a
// synthetic <html>), then let per-instance host values override.
if (readVariableDefaults && parseHostVariables && runtimeCompId) {
const mergedVariables = {
...readVariableDefaults(compDoc.documentElement),
...(innerRoot ? readVariableDefaults(innerRoot) : {}),
...parseHostVariables(hostEl),
};
if (Object.keys(mergedVariables).length > 0) {
variablesByComp[runtimeCompId] = mergedVariables;
}
}
// Scope one sub-composition <style> body. scopeRootSelectors keeps the
// sub-comp's html/body/:root rules from clobbering the host document (they
// are remapped to the composition box); see compositionScoping.
const scopeSubStyle = (raw: string): string => {
const css = rewriteCssAssetUrls(raw, src);
return scopeCompId
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
compoundAuthoredRoot: compoundAuthoredRoot === true,
scopeRootSelectors: true,
})
: css;
};
// When a sub-composition is a full HTML document (no <template>), styles
// and scripts in <head> are not part of contentDoc (which only has body
// content). Extract them so backgrounds, positioning, fonts, and library
// scripts (e.g. GSAP CDN) are not silently dropped.
if (!contentRoot && compDoc.head) {
for (const s of [...compDoc.head.querySelectorAll("style")]) {
styles.push(scopeSubStyle(s.textContent || ""));
}
for (const s of [...compDoc.head.querySelectorAll("script")]) {
const externalSrc = (s.getAttribute("src") || "").trim();
if (externalSrc) {
if (!externalScriptSrcs.includes(externalSrc)) {
externalScriptSrcs.push(externalSrc);
}
scriptItems.push({ kind: "external", src: externalSrc });
}
}
for (const link of [
...compDoc.head.querySelectorAll('link[rel="stylesheet"], link[rel="preconnect"]'),
]) {
const href = (link.getAttribute("href") || "").trim();
if (href && !seenLinkHrefs.has(href)) {
seenLinkHrefs.add(href);
const rel = (link.getAttribute("rel") || "").trim();
const crossorigin = link.hasAttribute("crossorigin")
? link.getAttribute("crossorigin") || ""
: undefined;
externalLinks.push({ href, rel, crossorigin });
}
}
}
// Extract styles from content
for (const s of [...contentDoc.querySelectorAll("style")]) {
styles.push(scopeSubStyle(s.textContent || ""));
s.remove();
}
// Extract scripts from content
for (const s of [...contentDoc.querySelectorAll("script")]) {
const externalSrc = (s.getAttribute("src") || "").trim();
if (externalSrc) {
if (!externalScriptSrcs.includes(externalSrc)) {
externalScriptSrcs.push(externalSrc);
}
scriptItems.push({ kind: "external", src: externalSrc });
} else {
const wrappedScript = scopeCompId
? wrapScopedCompositionScript(
s.textContent || "",
scopeCompId,
scriptErrorLabel,
runtimeScope || undefined,
runtimeCompId || scopeCompId,
authoredRootId,
)
: wrapInlineScriptWithErrorBoundary(s.textContent || "", scriptErrorLabel);
scripts.push(wrappedScript);
scriptItems.push({ kind: "inline", content: wrappedScript });
}
s.remove();
}
// Rewrite relative asset paths before inlining so ../foo.svg from
// compositions/ resolves correctly when the content moves to root.
const assetEls = innerRoot
? innerRoot.querySelectorAll("[src], [href]")
: contentDoc.querySelectorAll("[src], [href]");
rewriteAssetPaths(
assetEls,
src,
(el: Element, attr: string) => el.getAttribute(attr),
(el: Element, attr: string, val: string) => {
el.setAttribute(attr, val);
},
);
if (rewriteInlineStyles) {
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?.hasAttribute("data-timeline-locked")) {
hostEl.setAttribute("data-timeline-locked", "");
}
// Copy dimension attributes from inner root to host if missing
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);
}
}
// Inject content into the host element
if (innerRoot) {
innerRoot.setAttribute("data-composition-file", src);
for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
if (flattenInnerRoot) {
const prepared = flattenInnerRoot(innerRoot);
if (!compId && inferredCompId) {
// Anonymous host: flattenInnerRoot strips data-composition-id,
// assuming the host already carries the composition's identity.
// When the host has none, nothing in the render DOM matches the
// composition's own root-styling CSS or self-referencing scripts
// (e.g. document.querySelector('[data-composition-id="X"]')).
// Restore it on the wrapper so both keep resolving, same as
// before flattening preserved it via outerHTML.
prepared.setAttribute("data-composition-id", inferredCompId);
}
hostEl.innerHTML = prepared.outerHTML || "";
} else {
hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || "";
// When the producer path strips the inner root (innerHTML), the
// authored id attribute is lost. Propagate it to the host so that
// rewritten #ID selectors ([data-hf-authored-id="X"]) still resolve.
if (compId && authoredRootId) {
hostEl.setAttribute("data-hf-authored-id", authoredRootId);
}
}
} else {
for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();
// linkedom fragment parsing: when content is `<div data-composition-id="X">...</div>`,
// the div becomes documentElement and body is empty. Fall back to documentElement.outerHTML
// to preserve the composition wrapper.
const bodyHtml = contentDoc.body?.innerHTML || "";
hostEl.innerHTML = bodyHtml || contentDoc.documentElement?.outerHTML || "";
}
hostEl.setAttribute("data-composition-file", src);
hostEl.removeAttribute("data-composition-src");
}
return { styles, scripts, externalScriptSrcs, scriptItems, externalLinks, variablesByComp };
}
@@ -0,0 +1,7 @@
// Moved to @hyperframes/parsers. Re-exported here for back-compat.
export {
rewriteAssetPath,
rewriteAssetPaths,
rewriteInlineStyleAssetUrls,
rewriteCssAssetUrls,
} from "@hyperframes/parsers/asset-paths";
+41
View File
@@ -0,0 +1,41 @@
import { lintHyperframeHtml } from "@hyperframes/lint";
export type HyperframeStaticFailureReason =
| "missing_composition_id"
| "missing_composition_dimensions"
| "missing_timeline_registry"
| "invalid_script_syntax"
| "invalid_static_hyperframe_contract";
export type HyperframeStaticGuardResult = {
isValid: boolean;
missingKeys: string[];
failureReason: HyperframeStaticFailureReason | null;
};
export async function validateHyperframeHtmlContract(
html: string,
): Promise<HyperframeStaticGuardResult> {
const result = await lintHyperframeHtml(html);
const missingKeys = result.findings
.filter((finding) => finding.severity === "error")
.map((finding) => finding.message);
if (missingKeys.length === 0) {
return { isValid: true, missingKeys: [], failureReason: null };
}
const joined = missingKeys.join(" ").toLowerCase();
let failureReason: HyperframeStaticFailureReason = "invalid_static_hyperframe_contract";
if (joined.includes("data-composition-id")) {
failureReason = "missing_composition_id";
} else if (joined.includes("data-width") || joined.includes("data-height")) {
failureReason = "missing_composition_dimensions";
} else if (joined.includes("window.__timelines")) {
failureReason = "missing_timeline_registry";
} else if (joined.includes("script syntax")) {
failureReason = "invalid_script_syntax";
}
return { isValid: false, missingKeys, failureReason };
}
@@ -0,0 +1,2 @@
/** @deprecated Import from @hyperframes/parsers/sub-composition-validity */
export * from "@hyperframes/parsers/sub-composition-validity";
@@ -0,0 +1,266 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { describe, it, expect } from "vitest";
import {
compileTimingAttrs,
injectDurations,
extractResolvedMedia,
clampDurations,
} from "./timingCompiler.js";
// Raw 0x00 bytes in the HFMASK delimiters shipped once and broke every render
// under Bun's transpiler while behaving fine under Node (issue #2139) — only a
// byte-level check catches that, so keep the delimiters as \x00 escapes.
it("source contains no raw NUL bytes", () => {
const testPath = expect.getState().testPath ?? "";
const src = readFileSync(join(dirname(testPath), "timingCompiler.ts"), "latin1");
expect(src.includes("\x00")).toBe(false);
});
describe("compileTimingAttrs", () => {
it("adds data-end when data-start and data-duration are present on a video", () => {
const html = '<video id="v1" src="a.mp4" data-start="2" data-duration="5">';
const { html: compiled, unresolved } = compileTimingAttrs(html);
expect(compiled).toContain('data-end="7"');
expect(compiled).toContain('data-has-audio="true"');
expect(unresolved).toHaveLength(0);
});
it("injects a real id when the element has only data-hf-id (not a phantom match)", () => {
// Regression: getAttr(tag, "id") matched the trailing id="…" inside
// data-hf-id="…" and returned a phantom, so compileTag skipped its
// hf-video-N injection — leaving no real el.id and a blank-wash render.
const html = '<video data-hf-id="hf-bgvideo01" src="a.mp4" data-start="0" data-duration="2">';
const { html: compiled } = compileTimingAttrs(html);
expect(compiled).toContain('id="hf-video-0"');
expect(compiled).toContain('data-hf-id="hf-bgvideo01"');
expect(compiled).toContain('data-end="2"');
});
it("injects a real id on an audio element that has only data-hf-id", () => {
// Audio side of the same bug: the mixer selects `audio[id][src]`, so a
// phantom-id match meant the element was dropped (silent). compileTag must
// inject a real hf-audio-N so the mixer can find it.
const html = '<audio data-hf-id="hf-bgaudio01" src="a.mp3" data-start="0" data-duration="2">';
const { html: compiled } = compileTimingAttrs(html);
expect(compiled).toContain('id="hf-audio-0"');
expect(compiled).toContain('data-hf-id="hf-bgaudio01"');
});
it("leaves data-end unchanged when already present", () => {
const html = '<video id="v1" src="a.mp4" data-start="0" data-end="3">';
const { html: compiled, unresolved } = compileTimingAttrs(html);
expect(compiled).toContain('data-end="3"');
expect(compiled).not.toContain("data-duration");
expect(unresolved).toHaveLength(0);
});
it("marks muted videos as visual-only audio sources", () => {
const html = '<video id="v1" src="a.mp4" data-start="0" data-duration="3" muted playsinline>';
const { html: compiled } = compileTimingAttrs(html);
expect(compiled).toContain('data-has-audio="false"');
expect(compiled).not.toContain('data-has-audio="true"');
});
it("marks video as unresolved when data-duration and data-end are missing", () => {
const html = '<video id="v1" src="a.mp4" data-start="1">';
const { unresolved } = compileTimingAttrs(html);
expect(unresolved).toHaveLength(1);
expect(unresolved[0].id).toBe("v1");
expect(unresolved[0].tagName).toBe("video");
expect(unresolved[0].start).toBe(1);
});
it("auto-assigns ids to id-less videos so unresolved duration resolution can target them", () => {
const html = '<video src="a.mp4" data-start="1">';
const { html: compiled, unresolved } = compileTimingAttrs(html);
expect(compiled).toContain('id="hf-video-0"');
expect(compiled).toContain('data-has-audio="true"');
expect(unresolved).toHaveLength(1);
expect(unresolved[0].id).toBe("hf-video-0");
expect(unresolved[0].tagName).toBe("video");
expect(unresolved[0].start).toBe(1);
});
it("auto-injects data-start='0' when missing so video is discoverable", () => {
const html = '<video src="clip.mp4" muted>';
const { html: compiled, unresolved } = compileTimingAttrs(html);
expect(compiled).toContain('data-start="0"');
expect(compiled).toContain('id="hf-video-0"');
expect(unresolved).toHaveLength(1);
expect(unresolved[0].start).toBe(0);
});
it("marks auto-injected data-start with data-hf-auto-start sentinel", () => {
const html = '<video src="clip.mp4" muted>';
const { html: compiled } = compileTimingAttrs(html);
expect(compiled).toContain('data-start="0"');
expect(compiled).toContain("data-hf-auto-start");
});
it("does not add data-hf-auto-start when author provides data-start", () => {
const html = '<video id="v1" src="clip.mp4" data-start="5" muted>';
const { html: compiled } = compileTimingAttrs(html);
expect(compiled).toContain('data-start="5"');
expect(compiled).not.toContain("data-hf-auto-start");
});
it("compiles audio tags the same as video (minus data-has-audio)", () => {
const html = '<audio id="a1" src="music.mp3" data-start="0" data-duration="10">';
const { html: compiled } = compileTimingAttrs(html);
expect(compiled).toContain('data-end="10"');
expect(compiled).not.toContain("data-has-audio");
});
it("detects unresolved div/section elements with data-start but no data-end", () => {
const html = '<div id="comp1" data-start="0" data-composition-src="comp.html">';
const { unresolved } = compileTimingAttrs(html);
expect(unresolved).toHaveLength(1);
expect(unresolved[0].id).toBe("comp1");
expect(unresolved[0].tagName).toBe("div");
expect(unresolved[0].compositionSrc).toBe("comp.html");
});
it("does not report div as unresolved when data-end is present", () => {
const html = '<div id="comp1" data-start="0" data-end="5">';
const { unresolved } = compileTimingAttrs(html);
expect(unresolved).toHaveLength(0);
});
it("ignores media tags mentioned inside comments (issue #1938)", () => {
const html =
"<!-- this comment mentions a <video> and an <audio> tag -->\n<p>no media here</p>";
const { html: compiled, unresolved } = compileTimingAttrs(html);
// Comment text is preserved verbatim — no id/data-start/data-hf-auto-start injected.
expect(compiled).toBe(html);
expect(compiled).not.toContain("data-hf-auto-start");
expect(unresolved).toHaveLength(0);
});
it("ignores media tags inside <script> string literals", () => {
const html = '<script>const x = "<video src=\\"a.mp4\\">";</script>';
const { html: compiled, unresolved } = compileTimingAttrs(html);
expect(compiled).toBe(html);
expect(unresolved).toHaveLength(0);
});
it("still compiles real media tags alongside a comment that mentions them", () => {
const html =
'<!-- a <video> in prose -->\n<video src="a.mp4" data-start="0" data-duration="2">';
const { html: compiled } = compileTimingAttrs(html);
expect(compiled).toContain("<!-- a <video> in prose -->");
expect(compiled).toContain('id="hf-video-0"');
expect(compiled).toContain('data-end="2"');
});
it("preserves inert regions when compiled output is compiled again", () => {
const html = [
'<style>.hero::after { content: "$& $$ $` $\' <video>"; }</style>',
'<script>const markup = "$& $$ $` $\' <audio>";</script>',
'<video class="hero" src="a.mp4" data-start="0" data-duration="2">',
].join("\n");
const first = compileTimingAttrs(html).html;
const second = compileTimingAttrs(first).html;
expect(second).toContain('<style>.hero::after { content: "$& $$ $` $\' <video>"; }</style>');
expect(second).toContain('<script>const markup = "$& $$ $` $\' <audio>";</script>');
expect(second).toContain('data-end="2"');
expect(second).not.toContain("HFMASK");
});
});
describe("injectDurations", () => {
it("adds data-duration and data-end for resolved elements", () => {
const html = '<video id="v1" src="a.mp4" data-start="2">';
const result = injectDurations(html, [{ id: "v1", duration: 4 }]);
expect(result).toContain('data-duration="4"');
expect(result).toContain('data-end="6"');
});
it("injects durations for auto-assigned media ids", () => {
const { html, unresolved } = compileTimingAttrs('<video src="a.mp4" data-start="1">');
const result = injectDurations(html, [{ id: unresolved[0]!.id, duration: 4 }]);
expect(result).toContain('id="hf-video-0"');
expect(result).toContain('data-duration="4"');
expect(result).toContain('data-end="5"');
});
it("does not overwrite existing data-duration", () => {
const html = '<video id="v1" src="a.mp4" data-start="0" data-duration="3">';
const result = injectDurations(html, [{ id: "v1", duration: 10 }]);
// data-duration already present, should not be duplicated
expect(result).toContain('data-duration="3"');
});
});
describe("extractResolvedMedia", () => {
it("extracts video and audio elements with data-duration set", () => {
const html = [
'<video id="v1" src="vid.mp4" data-start="1" data-duration="5" data-media-start="0">',
'<audio id="a1" src="song.mp3" data-start="0" data-duration="10">',
'<video id="v2" src="other.mp4" data-start="0">', // no duration
].join("\n");
const resolved = extractResolvedMedia(html);
expect(resolved).toHaveLength(2);
expect(resolved[0].id).toBe("v1");
expect(resolved[0].tagName).toBe("video");
expect(resolved[0].duration).toBe(5);
expect(resolved[0].start).toBe(1);
expect(resolved[0].loop).toBe(false);
expect(resolved[1].id).toBe("a1");
expect(resolved[1].tagName).toBe("audio");
expect(resolved[1].duration).toBe(10);
});
it("marks looped media so render compilation can preserve display duration", () => {
const html = '<video id="v1" src="vid.webm" data-start="0" data-duration="4" loop>';
const resolved = extractResolvedMedia(html);
expect(resolved).toHaveLength(1);
expect(resolved[0]).toMatchObject({
id: "v1",
tagName: "video",
duration: 4,
loop: true,
});
});
it("skips elements with invalid durations", () => {
const html = '<video id="v1" src="a.mp4" data-start="0" data-duration="NaN">';
const resolved = extractResolvedMedia(html);
expect(resolved).toHaveLength(0);
});
});
describe("clampDurations", () => {
it("replaces data-duration and recomputes data-end", () => {
const html = '<video id="v1" src="a.mp4" data-start="2" data-duration="10" data-end="12">';
const result = clampDurations(html, [{ id: "v1", duration: 5 }]);
expect(result).toContain('data-duration="5"');
expect(result).toContain('data-end="7"');
});
});
@@ -0,0 +1,312 @@
/**
* Timing Compiler
*
* Shared, pure HTML compilation that normalizes timing attributes.
* Works in both Node.js and browser (no dependencies, regex-based).
*
* Guarantees every timed element gets:
* - id on media elements when missing
* - data-end (computed from data-start + data-duration when possible)
* - data-has-audio on <video> elements (false for muted visual-only videos)
*
* For elements without data-duration (e.g. videos relying on source duration),
* this compiler identifies them as "unresolved" so the caller can provide
* durations via an environment-specific resolver (ffprobe, el.duration, etc.)
* and call injectDurations() to complete the compilation.
*/
// ── Types ────────────────────────────────────────────────────────────────
export interface UnresolvedElement {
id: string;
tagName: string;
src?: string;
start: number;
end?: number;
duration?: number;
mediaStart: number;
compositionSrc?: string;
}
export interface ResolvedDuration {
id: string;
duration: number;
}
export interface ResolvedMediaElement {
id: string;
tagName: string;
src?: string;
start: number;
duration: number;
mediaStart: number;
loop: boolean;
}
export interface CompilationResult {
html: string;
unresolved: UnresolvedElement[];
}
// ffprobe precision can differ slightly across local and CI media stacks. Also
// the floor for the engine's hold-last-frame tolerance (a slot left unclamped is
// short by at most this), so they must move together.
export const MEDIA_DURATION_CLAMP_EPSILON_SECONDS = 0.05;
export function shouldClampMediaDuration(declaredDuration: number, maxDuration: number): boolean {
return declaredDuration > maxDuration + MEDIA_DURATION_CLAMP_EPSILON_SECONDS;
}
// ── Helpers ──────────────────────────────────────────────────────────────
function getAttr(tag: string, attr: string): string | null {
// `(?<![\w-])` anchors the attribute name to a fresh start. Without it,
// `getAttr(tag, "id")` matches the trailing `id="…"` inside `data-hf-id="…"`
// (and "src" inside `data-src`, etc.) and returns a phantom value. That bug
// made compileTag believe a Studio-stamped `data-hf-id`-only element already
// had an `id`, so it skipped its `hf-video-N` injection — leaving the element
// with no real `el.id`, which the render pipeline keys off of (blank wash).
const match = tag.match(new RegExp(`(?<![\\w-])${attr}=["']([^"']+)["']`));
return match ? (match[1] ?? null) : null;
}
function hasAttr(tag: string, attr: string): boolean {
return new RegExp(`\\s${attr}(?:\\s|=|>|/)`).test(tag);
}
function injectAttr(tag: string, attr: string, value: string): string {
return tag.replace(/>$/, ` ${attr}="${value}">`);
}
// Real media/timing elements never live inside comments, <script>, or <style>.
// The tag regexes below aren't comment-aware, so a comment that merely mentions
// `<video>`/`<audio>` gets rewritten as if it were a real element (issue #1938).
// Mask those inert regions with placeholders (no `<`, so the tag regexes skip
// them) before scanning, then restore them verbatim.
const INERT_REGION_RE =
/<!--[\s\S]*?-->|<script\b[\s\S]*?<\/script\s*>|<style\b[\s\S]*?<\/style\s*>/gi;
// The NUL delimiters must stay as \u0000 escapes: raw 0x00 bytes make this file
// binary to git and are corrupted by Bun's transpiler when bundled (issue #2139).
function maskInertRegions(html: string): { masked: string; restore: (s: string) => string } {
const stash: string[] = [];
const masked = html.replace(INERT_REGION_RE, (region) => {
const token = `\u0000HFMASK${stash.length}\u0000`;
stash.push(region);
return token;
});
const restore = (s: string): string =>
// oxlint-disable-next-line no-control-regex -- NUL cannot appear in HTML, which is what makes it a safe mask delimiter
s.replace(/\u0000HFMASK(\d+)\u0000/g, (_, i) => stash[Number(i)] ?? "");
return { masked, restore };
}
// ── Core compilation ─────────────────────────────────────────────────────
function compileTag(
tag: string,
isVideo: boolean,
generateId: () => number,
): { tag: string; unresolved: UnresolvedElement | null } {
let result = tag;
let unresolved: UnresolvedElement | null = null;
let id = getAttr(result, "id");
if (!id) {
id = `${isVideo ? "hf-video" : "hf-audio"}-${generateId()}`;
result = injectAttr(result, "id", id);
}
let startStr = getAttr(result, "data-start");
if (startStr === null) {
result = injectAttr(result, "data-start", "0");
result = injectAttr(result, "data-hf-auto-start", "");
startStr = "0";
}
const start = parseFloat(startStr);
const mediaStartStr = getAttr(result, "data-media-start");
const mediaStart = mediaStartStr ? parseFloat(mediaStartStr) : 0;
// 1. Compute data-end from data-start + data-duration
if (!hasAttr(result, "data-end")) {
const durationStr = getAttr(result, "data-duration");
if (durationStr !== null) {
const end = start + parseFloat(durationStr);
result = injectAttr(result, "data-end", String(end));
} else if (id) {
// No data-duration: mark as unresolved so caller can provide it
unresolved = {
id,
tagName: isVideo ? "video" : "audio",
src: getAttr(result, "src") ?? undefined,
start,
mediaStart,
};
}
}
// 2. Add data-has-audio to <video> elements. Muted videos are visual-only by
// contract; audible media should be represented by either an unmuted video
// with data-has-audio="true" or a separate <audio> element.
if (isVideo && !hasAttr(result, "data-has-audio")) {
result = injectAttr(result, "data-has-audio", hasAttr(result, "muted") ? "false" : "true");
}
return { tag: result, unresolved };
}
/**
* Compile timing attributes in HTML.
*
* Phase 1 (static): Adds data-end where data-duration exists,
* adds data-has-audio on videos.
*
* Returns the compiled HTML and a list of elements that could not be
* resolved statically (missing data-duration). The caller should resolve
* these via ffprobe / el.duration and call injectDurations().
*/
export function compileTimingAttrs(html: string): CompilationResult {
const unresolved: UnresolvedElement[] = [];
let nextVideoId = 0;
let nextAudioId = 0;
const { masked, restore } = maskInertRegions(html);
html = masked;
// Process <video ...> tags
html = html.replace(/<video[^>]*>/gi, (match) => {
const { tag, unresolved: u } = compileTag(match, true, () => nextVideoId++);
if (u) unresolved.push(u);
return tag;
});
// Process <audio ...> tags
html = html.replace(/<audio[^>]*>/gi, (match) => {
const { tag, unresolved: u } = compileTag(match, false, () => nextAudioId++);
if (u) unresolved.push(u);
return tag;
});
// Identify unresolved timed elements (divs with data-start but no data-end/data-duration)
// These are typically compositions whose duration depends on GSAP timelines
html.replace(/<(?:div|section)[^>]*>/gi, (match) => {
if (!hasAttr(match, "data-start")) return match;
if (hasAttr(match, "data-end") || hasAttr(match, "data-duration")) return match;
const id = getAttr(match, "id");
const compositionSrc = getAttr(match, "data-composition-src");
if (id) {
const startStr = getAttr(match, "data-start");
unresolved.push({
id,
tagName: "div",
start: startStr ? parseFloat(startStr) : 0,
mediaStart: 0,
compositionSrc: compositionSrc ?? undefined,
});
}
return match;
});
return { html: restore(html), unresolved };
}
/**
* Inject resolved durations into compiled HTML.
*
* For each resolved element, adds data-duration and data-end attributes.
* Call this after resolving durations via ffprobe, el.duration, or
* GSAP timeline queries.
*/
export function injectDurations(html: string, resolutions: ResolvedDuration[]): string {
for (const { id, duration } of resolutions) {
// Match the element's opening tag by id
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
html = html.replace(idPattern, (tag) => {
let result = tag;
// Add data-duration if missing
if (!hasAttr(result, "data-duration")) {
result = injectAttr(result, "data-duration", String(duration));
}
// Add data-end if missing
if (!hasAttr(result, "data-end")) {
const startStr = getAttr(result, "data-start");
const start = startStr ? parseFloat(startStr) : 0;
result = injectAttr(result, "data-end", String(start + duration));
}
return result;
});
}
return html;
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Extract video/audio elements that already have data-duration set.
* Used by callers to validate declared durations against actual source durations.
*/
export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
const resolved: ResolvedMediaElement[] = [];
html = maskInertRegions(html).masked;
const mediaRegex = /<(?:video|audio)[^>]*>/gi;
let match: RegExpExecArray | null;
while ((match = mediaRegex.exec(html)) !== null) {
const tag = match[0];
const id = getAttr(tag, "id");
const durationStr = getAttr(tag, "data-duration");
if (!id || durationStr === null) continue;
const duration = parseFloat(durationStr);
if (!Number.isFinite(duration) || duration <= 0) continue;
const isVideo = /^<video/i.test(tag);
const startStr = getAttr(tag, "data-start");
const mediaStartStr = getAttr(tag, "data-media-start");
resolved.push({
id,
tagName: isVideo ? "video" : "audio",
src: getAttr(tag, "src") ?? undefined,
start: startStr !== null ? parseFloat(startStr) : 0,
duration,
mediaStart: mediaStartStr ? parseFloat(mediaStartStr) : 0,
loop: hasAttr(tag, "loop"),
});
}
return resolved;
}
/**
* Clamp existing data-duration and data-end on media elements.
* For each resolution, replaces the declared duration with the clamped value
* and recomputes data-end accordingly.
*/
export function clampDurations(html: string, clamps: ResolvedDuration[]): string {
for (const { id, duration } of clamps) {
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
html = html.replace(idPattern, (tag) => {
// Replace data-duration value
tag = tag.replace(/data-duration=["'][^"']*["']/, `data-duration="${duration}"`);
// Recompute data-end from data-start + clamped duration
const startStr = getAttr(tag, "data-start");
const start = startStr ? parseFloat(startStr) : 0;
tag = tag.replace(/data-end=["'][^"']*["']/, `data-end="${start + duration}"`);
return tag;
});
}
return html;
}
@@ -0,0 +1,219 @@
/**
* WS-C — timingResolver tests.
*
* The resolver is pure (no DOM, no Date.now, no Math.random) so it can be
* unit-tested directly. These tests also serve as the preview==render parity
* fixture: the resolver produces the exact same output regardless of whether
* it is called from the preview path (session layer) or the render path
* (timingCompiler). A golden parity test at the end confirms both paths
* produce identical enter/exit from the same resolver call.
*/
import { describe, it, expect } from "vitest";
import {
resolveTimings,
type AuthoredTiming,
type WordTiming,
type ElementAnchor,
} from "./timingResolver.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function authored(hfId: string, start: number, duration: number): AuthoredTiming {
return { hfId, start, duration };
}
function word(index: number, start: number, end: number): WordTiming {
return { index, start, end };
}
function anchor(
hfId: string,
wordIndex: number,
enterDuration: number,
exitDuration: number,
slotEnd: number,
enterOffset?: number,
): ElementAnchor {
return { hfId, wordIndex, enterDuration, exitDuration, slotEnd, enterOffset };
}
// ─── Un-anchored elements keep authored timing ────────────────────────────────
describe("resolveTimings — un-anchored elements", () => {
it("returns authored start/duration unchanged when no anchors supplied", () => {
const result = resolveTimings({
elements: [authored("hf-a", 1, 2), authored("hf-b", 3, 1.5)],
wordTimings: [],
anchors: [],
});
expect(result["hf-a"]).toEqual({ enterAt: 1, exitAt: 3, holdDuration: 0 });
expect(result["hf-b"]).toEqual({ enterAt: 3, exitAt: 4.5, holdDuration: 0 });
});
it("align-on-adjust: anchored and un-anchored elements in same call", () => {
const result = resolveTimings({
elements: [authored("hf-anchored", 0, 3), authored("hf-free", 4, 2)],
wordTimings: [word(0, 1.0, 1.5)],
anchors: [anchor("hf-anchored", 0, 0.5, 0.5, 3.0)],
});
// Anchored: enters at word 0 start (1.0), enterDuration=0.5, exitDuration=0.5
// slot=3.0 → holdDuration = max(0, 3.0 - (1.0 + 0.5 + 0.5)) = 1.0
// exitAt = 1.0 + 0.5 + 1.0 + 0.5 = 3.0
expect(result["hf-anchored"]).toEqual({ enterAt: 1.0, exitAt: 3.0, holdDuration: 1.0 });
// Un-anchored: keeps authored timing
expect(result["hf-free"]).toEqual({ enterAt: 4, exitAt: 6, holdDuration: 0 });
});
});
// ─── Word-anchored elements ───────────────────────────────────────────────────
describe("resolveTimings — word-anchored elements", () => {
it("anchors element enterAt to word start", () => {
const result = resolveTimings({
elements: [authored("hf-x", 0, 2)],
wordTimings: [word(0, 0.5, 1.0), word(1, 1.5, 2.0)],
anchors: [anchor("hf-x", 1, 0.3, 0.2, 2.5)],
});
// enterAt = wordTimings[1].start = 1.5; enterDuration=0.3, exitDuration=0.2
// holdDuration = max(0, 2.5 - (1.5 + 0.3 + 0.2)) = max(0, 0.5) = 0.5
// exitAt = 1.5 + 0.3 + 0.5 + 0.2 = 2.5
expect(result["hf-x"]).toEqual({ enterAt: 1.5, exitAt: 2.5, holdDuration: 0.5 });
});
it("enterOffset shifts enterAt relative to word start", () => {
const result = resolveTimings({
elements: [authored("hf-y", 0, 1)],
wordTimings: [word(0, 2.0, 2.5)],
anchors: [anchor("hf-y", 0, 0.2, 0.1, 4.0, 0.3)],
});
// enterAt = 2.0 + 0.3 = 2.3
// holdDuration = max(0, 4.0 - (2.3 + 0.2 + 0.1)) = 1.4
// exitAt = 2.3 + 0.2 + 1.4 + 0.1 = 4.0
expect(result["hf-y"]?.enterAt).toBeCloseTo(2.3);
expect(result["hf-y"]?.exitAt).toBeCloseTo(4.0);
expect(result["hf-y"]?.holdDuration).toBeCloseTo(1.4);
});
});
// ─── Elastic hold math ────────────────────────────────────────────────────────
describe("resolveTimings — elastic hold math", () => {
it("holdDuration = max(0, slotEnd - (enterAt + enterDuration + exitDuration))", () => {
const result = resolveTimings({
elements: [authored("hf-z", 0, 1)],
wordTimings: [word(0, 0.0, 0.5)],
anchors: [anchor("hf-z", 0, 0.5, 0.5, 3.0)],
});
// enterAt=0, holdDuration = max(0, 3.0 - (0 + 0.5 + 0.5)) = 2.0
expect(result["hf-z"]).toEqual({ enterAt: 0, exitAt: 3.0, holdDuration: 2.0 });
});
it("clamps holdDuration >= 0 when slot is too tight", () => {
const result = resolveTimings({
elements: [authored("hf-tight", 0, 2)],
wordTimings: [word(0, 5.0, 5.5)],
// enter=1.0, exit=1.0, slotEnd=5.5 → slot=5.5-(5.0+1.0+1.0)=-1.5 → clamp to 0
anchors: [anchor("hf-tight", 0, 1.0, 1.0, 5.5)],
});
expect(result["hf-tight"]?.holdDuration).toBe(0);
// exitAt = 5.0 + 1.0 + 0 + 1.0 = 7.0 (element exits after its natural duration)
expect(result["hf-tight"]?.exitAt).toBe(7.0);
});
it("holdDuration is zero (not negative) when exactly at slot boundary", () => {
const result = resolveTimings({
elements: [authored("hf-exact", 0, 1)],
wordTimings: [word(0, 1.0, 1.5)],
// enterAt=1.0, slotEnd=1.0+0.3+0.2=1.5 → holdDuration=0
anchors: [anchor("hf-exact", 0, 0.3, 0.2, 1.5)],
});
expect(result["hf-exact"]?.holdDuration).toBe(0);
expect(result["hf-exact"]?.exitAt).toBeCloseTo(1.5);
});
});
// ─── Missing word index falls back gracefully ────────────────────────────────
describe("resolveTimings — missing word index", () => {
it("falls back to wordStart=0 when word index is not in wordTimings", () => {
const result = resolveTimings({
elements: [authored("hf-missing", 5, 2)],
wordTimings: [word(0, 1.0, 1.5)],
// wordIndex 99 doesn't exist → wordStart defaults to 0
anchors: [anchor("hf-missing", 99, 0.5, 0.5, 2.0)],
});
// enterAt = 0 + 0 = 0
// holdDuration = max(0, 2.0 - (0 + 0.5 + 0.5)) = 1.0
expect(result["hf-missing"]?.enterAt).toBe(0);
expect(result["hf-missing"]?.holdDuration).toBe(1.0);
});
});
// ─── Determinism ─────────────────────────────────────────────────────────────
describe("resolveTimings — determinism", () => {
it("produces identical output for identical input (no hidden state)", () => {
const input = {
elements: [authored("hf-det", 0, 2), authored("hf-free2", 3, 1)],
wordTimings: [word(0, 0.5, 1.0)],
anchors: [anchor("hf-det", 0, 0.3, 0.2, 2.0)],
};
const r1 = resolveTimings(input);
const r2 = resolveTimings(input);
expect(r1).toEqual(r2);
});
});
// ─── Preview == render parity golden test ────────────────────────────────────
//
// This test mirrors the contract of time_control.py in the backend render path.
// Both the preview path (SDK session) and the render path (timingCompiler
// consumer) call resolveTimings() with the same input and MUST produce the
// same output. Since there is exactly one implementation, calling it twice with
// the same args is the parity test: they cannot diverge.
//
// NOTE: happy-dom cannot do GSAP layout/seek operations so the GSAP-seek path
// (smart-seek) is exercised by timingCompiler.test.ts (Node.js) separately.
// The purity of resolveTimings() means this test fully covers resolver logic.
// NOTE: this asserts a PROPERTY of the pure resolver (same input → same output),
// not a guarantee about the two live paths — neither preview nor render calls
// resolveTimings yet (see timingResolver.ts header). It is a forward fixture for
// when they do, not proof they currently agree.
describe("resolveTimings — determinism fixture for future preview/render wiring", () => {
it("resolver output is identical for identical input (one impl → no drift once wired)", () => {
// Golden fixture: 3 elements, 2 words, 1 anchored, 2 free.
const elements: AuthoredTiming[] = [
authored("hf-title", 0, 2.0), // anchored
authored("hf-sub", 3.0, 1.5), // free
authored("hf-cta", 5.0, 1.0), // free
];
const wordTimings: WordTiming[] = [word(0, 0.0, 0.5), word(1, 1.0, 1.8)];
const anchors: ElementAnchor[] = [
anchor("hf-title", 1, 0.4, 0.3, 3.5), // anchored to word 1
];
// Simulate preview call (same input as would arrive from session layer)
const previewResult = resolveTimings({ elements, wordTimings, anchors });
// Simulate render call (same input as would arrive from timingCompiler)
const renderResult = resolveTimings({ elements, wordTimings, anchors });
// Identical because it is one pure function — this becomes the live
// "preview == render" guarantee only once both paths actually call it.
expect(previewResult).toEqual(renderResult);
// Spot-check the anchored element's resolved values:
// enterAt = word[1].start = 1.0 (no offset)
// holdDuration = max(0, 3.5 - (1.0 + 0.4 + 0.3)) = max(0, 1.8) = 1.8
// exitAt = 1.0 + 0.4 + 1.8 + 0.3 = 3.5
expect(previewResult["hf-title"]).toEqual({ enterAt: 1.0, exitAt: 3.5, holdDuration: 1.8 });
// Free elements keep authored timing
expect(previewResult["hf-sub"]).toEqual({ enterAt: 3.0, exitAt: 4.5, holdDuration: 0 });
expect(previewResult["hf-cta"]).toEqual({ enterAt: 5.0, exitAt: 6.0, holdDuration: 0 });
});
});
@@ -0,0 +1,157 @@
/**
* Shared pure timing resolver — WS-C.
*
* resolveTimings() is the single intended implementation of word-anchored
* elastic timing, designed to be the one code path that BOTH the preview
* (session layer in @hyperframes/sdk) and render (timingCompiler.ts +
* htmlBundler) sides call so they cannot drift apart.
*
* NOT YET WIRED: neither path consumes it yet — the anchor-producing inputs
* (TTS word timings) arrive on the Pacific/backend side, which is deferred.
* Until a real caller lands, the "preview == render" parity below is a property
* of the resolver (one pure function) rather than a guarantee the two live
* paths currently share. Wire it into timingCompiler and session before
* relying on it for parity.
*
* Constraints:
* - Deterministic + pure: no Date.now(), no Math.random(), no DOM, no I/O.
* - Never timescale animated content: elastic hold extends the hold window,
* not tween durations.
* - Align-on-adjust: only explicitly anchored elements become word-locked;
* un-anchored elements keep their authored start/duration unchanged.
* - Elastic hold: holdDuration = max(0, slot (enter + exit)), clamped ≥ 0.
*/
// ── Types ────────────────────────────────────────────────────────────────────
export interface WordTiming {
/** Word index (0-based) */
index: number;
/** Absolute start time of this word in seconds */
start: number;
/** Absolute end time of this word in seconds */
end: number;
}
export interface ElementAnchor {
/** Which element this anchor applies to */
hfId: string;
/**
* Index of the word in `wordTimings` this element is anchored to.
* The element's enterAt = wordTimings[wordIndex].start + enterOffset.
*/
wordIndex: number;
/**
* Offset in seconds from the anchored word's start time to the element's enter.
* Defaults to 0.
*/
enterOffset?: number;
/**
* The authored enter duration (time from element start until hold begins).
* Used to compute the hold slot.
*/
enterDuration: number;
/**
* The authored exit duration (time from hold end until element exits).
* Used to compute the hold slot.
*/
exitDuration: number;
/**
* The "slot" end time: the element must finish by this time.
* holdDuration = max(0, slotEnd - (enterAt + enterDuration + exitDuration))
*/
slotEnd: number;
}
export interface AuthoredTiming {
hfId: string;
/** Authored data-start value in seconds */
start: number;
/** Authored duration in seconds (data-duration or data-end - data-start) */
duration: number;
}
export interface ResolvedTiming {
enterAt: number;
exitAt: number;
/** Computed elastic hold duration (>= 0). Non-anchored elements have holdDuration = 0. */
holdDuration: number;
}
export interface ResolveTimingsInput {
/** All authored element timings (both anchored and un-anchored). */
elements: AuthoredTiming[];
/** TTS word timings from the backend. */
wordTimings: WordTiming[];
/** The set of elements that are word-anchored. Only these get word-locked. */
anchors: ElementAnchor[];
}
export type ResolveTimingsResult = Record<string, ResolvedTiming>;
// ── Resolver ─────────────────────────────────────────────────────────────────
/**
* Resolve element timings for a composition with optional word-anchored elements.
*
* Align-on-adjust rule: only elements with an explicit anchor in `anchors` are
* word-locked. All others keep their authored start/duration unchanged.
*
* Elastic hold: for anchored elements, the hold window is expanded to fill the
* slot without timescaling animated content. The hold duration is:
* holdDuration = max(0, slotEnd - (enterAt + enterDuration + exitDuration))
*
* @param input - Elements, word timings, and anchor map.
* @returns A map from hfId to resolved { enterAt, exitAt, holdDuration }.
*/
export function resolveTimings(input: ResolveTimingsInput): ResolveTimingsResult {
const { elements, wordTimings, anchors } = input;
// Build anchor lookup by hfId for O(1) access.
const anchorMap = new Map<string, ElementAnchor>();
for (const anchor of anchors) {
anchorMap.set(anchor.hfId, anchor);
}
// Build word timing lookup by index for O(1) access.
const wordMap = new Map<number, WordTiming>();
for (const wt of wordTimings) {
wordMap.set(wt.index, wt);
}
const result: ResolveTimingsResult = {};
for (const el of elements) {
const anchor = anchorMap.get(el.hfId);
if (anchor === undefined) {
// Un-anchored: keep authored timing exactly as-is.
result[el.hfId] = {
enterAt: el.start,
exitAt: el.start + el.duration,
holdDuration: 0,
};
continue;
}
// Word-anchored: compute enter from the word timing.
const word = wordMap.get(anchor.wordIndex);
const wordStart = word !== undefined ? word.start : 0;
const enterOffset = anchor.enterOffset ?? 0;
const enterAt = wordStart + enterOffset;
// Elastic hold: expand hold to fill the slot, clamped >= 0.
// holdDuration = max(0, slotEnd - (enterAt + enterDuration + exitDuration))
const holdDuration = Math.max(
0,
anchor.slotEnd - (enterAt + anchor.enterDuration + anchor.exitDuration),
);
// exitAt = enterAt + enterDuration + hold + exitDuration
const exitAt = enterAt + anchor.enterDuration + holdDuration + anchor.exitDuration;
result[el.hfId] = { enterAt, exitAt, holdDuration };
}
return result;
}
+194
View File
@@ -0,0 +1,194 @@
// ── Shared cross-package types ──────────────────────────────────────────────
export type ExecutionMode = "planning" | "design" | "execution" | null;
// ── Frame rate ──────────────────────────────────────────────────────────────
/**
* Frame rate as an exact rational. Carrying `{num, den}` end-to-end (rather
* than collapsing to `29.97`) lets us pass NTSC / drop-frame rates straight
* through to FFmpeg via `-r 30000/1001` without any decimal round-trip.
*
* Integer fps is represented with `den: 1` (e.g. `{ num: 30, den: 1 }`).
*
* Use {@link fpsToNumber} when arithmetic forces a decimal (e.g. `setTimeout`
* intervals) and {@link fpsToFfmpegArg} when emitting FFmpeg `-r` /
* `-framerate` strings.
*/
export interface Fps {
num: number;
den: number;
}
export type FpsInput = number | Fps;
export function toFps(input: FpsInput): Fps {
if (typeof input === "number") {
return { num: input, den: 1 };
}
return input;
}
/**
* Decimal value of an {@link Fps} rational. Used at sites that need a
* `number` for arithmetic (frame-index → time, frame intervals, telemetry
* payloads) where the small precision loss of the decimal is acceptable.
*/
export function fpsToNumber(fps: Fps): number {
return fps.num / fps.den;
}
/**
* FFmpeg-style fps argument. Returns `"30"` for integer fps and `"30000/1001"`
* for rationals — both forms are accepted verbatim by FFmpeg's `-r` and
* `-framerate` flags. We keep integer fps as a bare integer so existing
* snapshot tests / log output don't churn for the common case.
*/
export function fpsToFfmpegArg(fps: Fps): string {
return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`;
}
/**
* Discriminated parse result for {@link parseFps}. Lets the CLI / route
* validation own its own error UX without losing the structured failure
* reason.
*/
export type FpsParseResult =
| { ok: true; value: Fps }
| {
ok: false;
reason:
| "empty"
| "not-a-number"
| "non-positive"
| "out-of-range"
| "invalid-fraction"
| "ambiguous-decimal";
};
/**
* Parse a user-supplied fps spec into an {@link Fps} rational.
*
* Accepted forms:
* - integer string `"30"` → `{ num: 30, den: 1 }`
* - integer number `30` → `{ num: 30, den: 1 }`
* - rational string `"30000/1001"` → `{ num: 30000, den: 1001 }` (exact NTSC)
*
* Rejected:
* - empty / non-numeric input
* - decimals like `"29.97"` — callers must spell rationals with `/` so the
* exact denominator is unambiguous (FFmpeg treats `29.97` as a slightly
* different framerate than `30000/1001`).
* - division by zero, negative or zero numerator
* - decimal value outside `[1, 240]` — defensive bounds for "human" fps
* ranges (24, 25, 30, 50, 60, 120, 240, plus the NTSC trio).
*/
export function parseFps(input: string | number): FpsParseResult {
if (typeof input === "number") {
if (!Number.isFinite(input)) return { ok: false, reason: "not-a-number" };
if (!Number.isInteger(input)) return { ok: false, reason: "ambiguous-decimal" };
if (input <= 0) return { ok: false, reason: "non-positive" };
if (input > 240) return { ok: false, reason: "out-of-range" };
return { ok: true, value: { num: input, den: 1 } };
}
const raw = input.trim();
if (raw === "") return { ok: false, reason: "empty" };
if (raw.includes("/")) {
const parts = raw.split("/");
if (parts.length !== 2) return { ok: false, reason: "invalid-fraction" };
const num = Number(parts[0]);
const den = Number(parts[1]);
if (!Number.isFinite(num) || !Number.isFinite(den)) {
return { ok: false, reason: "not-a-number" };
}
if (!Number.isInteger(num) || !Number.isInteger(den)) {
return { ok: false, reason: "invalid-fraction" };
}
if (den <= 0) return { ok: false, reason: "invalid-fraction" };
if (num <= 0) return { ok: false, reason: "non-positive" };
const decimal = num / den;
if (decimal < 1 || decimal > 240) return { ok: false, reason: "out-of-range" };
return { ok: true, value: { num, den } };
}
// Integer-only path — reject `"29.97"` so users are explicit about the
// exact rational they want.
if (!/^-?\d+$/.test(raw)) {
// Allow caller to differentiate "29.97" from "abc" if they want; both
// are user errors but the message can be friendlier for decimals.
if (/^-?\d*\.\d+$/.test(raw)) return { ok: false, reason: "ambiguous-decimal" };
return { ok: false, reason: "not-a-number" };
}
const n = Number(raw);
if (n <= 0) return { ok: false, reason: "non-positive" };
if (n > 240) return { ok: false, reason: "out-of-range" };
return { ok: true, value: { num: n, den: 1 } };
}
/**
* Convenience wrapper around {@link parseFps} for callsites that want the
* default-30-fps fallback when input is `undefined`. Does NOT swallow parse
* errors — those still surface via the discriminated result.
*/
export function parseFpsWithDefault(input: string | number | undefined): FpsParseResult {
if (input === undefined || input === "") return { ok: true, value: { num: 30, den: 1 } };
return parseFps(input);
}
/** Video orientation / aspect ratio. */
export type Orientation = "16:9" | "9:16";
// ── Re-exports from @hyperframes/parsers (moved in refactor) ─────────────────
// @deprecated — import from @hyperframes/parsers directly
export type {
Asset,
TimelineElementType,
MediaElementType,
TimelineElementBase,
TimelineMediaElement,
WaveformData,
TimelineTextElement,
TimelineCompositionElement,
CompositionVariableType,
CompositionVariableBase,
StringVariable,
NumberVariable,
ColorVariable,
BooleanVariable,
EnumVariable,
CompositionVariable,
CompositionSpec,
TimelineElement,
MediaFile,
CanvasResolution,
CompositionAPI,
PlayerAPI,
AddElementData,
ValidationResult,
CompositionAsset,
Keyframe,
KeyframeProperties,
ElementKeyframes,
StageZoom,
StageZoomKeyframe,
} from "@hyperframes/parsers";
export {
CANVAS_DIMENSIONS,
VALID_CANVAS_RESOLUTIONS,
normalizeResolutionFlag,
checkOutputResolutionCompatibility,
COMPOSITION_VARIABLE_TYPES,
TIMELINE_COLORS,
DEFAULT_DURATIONS,
getDefaultStageZoom,
isTextElement,
isMediaElement,
isCompositionElement,
} from "@hyperframes/parsers";
export type {
OutputResolutionCompatibility,
OutputResolutionIssueKind,
} from "@hyperframes/parsers";
@@ -0,0 +1,188 @@
import { describe, expect, it } from "vitest";
import {
resolveEditingAffordances,
resolveEditingSections,
type EditableElementFacts,
} from "./affordances";
function baseFacts(over: Partial<EditableElementFacts> = {}): EditableElementFacts {
return {
hasStableTarget: true,
tag: "div",
inlineStyles: {},
computedStyles: {},
isCompositionHost: false,
isCompositionRoot: false,
isInsideLockedComposition: false,
isMasterView: false,
existsInSource: true,
hasEditableText: false,
hasTimingStart: false,
animationCount: 0,
...over,
};
}
describe("resolveEditingAffordances — capabilities", () => {
it("locked composition: nothing editable, not selectable, reason set", () => {
const a = resolveEditingAffordances(baseFacts({ isInsideLockedComposition: true }));
expect(a.capabilities).toMatchObject({
canSelect: false,
canEditStyles: false,
canMove: false,
});
expect(a.capabilities.reasonIfDisabled).toContain("locked composition");
});
it("no stable target: not editable but selectable", () => {
const a = resolveEditingAffordances(baseFacts({ hasStableTarget: false }));
expect(a.capabilities.canSelect).toBe(true);
expect(a.capabilities.canEditStyles).toBe(false);
});
it("not in source: script-generated, select-only", () => {
const a = resolveEditingAffordances(baseFacts({ existsInSource: false }));
expect(a.capabilities.canSelect).toBe(true);
expect(a.capabilities.canEditStyles).toBe(false);
expect(a.capabilities.reasonIfDisabled).toContain("generated by a script");
});
it("composition root: edit styles only, no move/resize, and NOT croppable (it is the canvas)", () => {
const a = resolveEditingAffordances(baseFacts({ isCompositionRoot: true }));
expect(a.capabilities).toMatchObject({ canEditStyles: true, canMove: false, canResize: false });
expect(a.capabilities.canCrop).toBe(false);
});
it("sub-composition host in master view: styles locked but STILL croppable (viewport clip)", () => {
const a = resolveEditingAffordances(baseFacts({ isCompositionHost: true, isMasterView: true }));
expect(a.capabilities.canEditStyles).toBe(false); // internals are edited by drilling in
expect(a.capabilities.canCrop).toBe(true); // crop is a viewport clip on the host in the parent source
});
it("script-generated / locked elements are not croppable", () => {
expect(
resolveEditingAffordances(baseFacts({ existsInSource: false })).capabilities.canCrop,
).toBe(false);
expect(
resolveEditingAffordances(baseFacts({ isInsideLockedComposition: true })).capabilities
.canCrop,
).toBe(false);
});
it("absolute + left/top + identity transform: canMove", () => {
const a = resolveEditingAffordances(
baseFacts({
computedStyles: { position: "absolute", left: "10px", top: "20px", transform: "none" },
}),
);
expect(a.capabilities.canMove).toBe(true);
});
it("transform-driven geometry blocks canMove", () => {
const a = resolveEditingAffordances(
baseFacts({
computedStyles: {
position: "absolute",
left: "10px",
top: "20px",
transform: "matrix(1,0,0,1,5,5)",
},
}),
);
expect(a.capabilities.canMove).toBe(false);
});
it("canResize requires canMove plus a width/height", () => {
const a = resolveEditingAffordances(
baseFacts({
computedStyles: {
position: "absolute",
left: "0px",
top: "0px",
width: "100px",
transform: "none",
},
}),
);
expect(a.capabilities.canResize).toBe(true);
});
it("inline left/top override missing computed", () => {
const a = resolveEditingAffordances(
baseFacts({
inlineStyles: { left: "5px", top: "5px" },
computedStyles: { position: "fixed", transform: "none" },
}),
);
expect(a.capabilities.canMove).toBe(true);
});
it("computedStyles absent: canMove/canResize default false", () => {
const a = resolveEditingAffordances(
baseFacts({ computedStyles: undefined, inlineStyles: { left: "5px", top: "5px" } }),
);
expect(a.capabilities.canMove).toBe(false);
expect(a.capabilities.canResize).toBe(false);
});
it("composition host + master view: no edit styles, geometry blocked", () => {
const a = resolveEditingAffordances(baseFacts({ isCompositionHost: true, isMasterView: true }));
expect(a.capabilities.canEditStyles).toBe(false);
expect(a.capabilities.canApplyManualOffset).toBe(false);
expect(a.capabilities.reasonIfDisabled).toContain("internal layer");
});
});
describe("resolveEditingAffordances — sections", () => {
it("video: media + colorGrading", () => {
const s = resolveEditingAffordances(baseFacts({ tag: "video" })).sections;
expect(s).toMatchObject({ media: true, colorGrading: true });
});
it("audio: media but not colorGrading", () => {
const s = resolveEditingAffordances(baseFacts({ tag: "audio" })).sections;
expect(s).toMatchObject({ media: true, colorGrading: false });
});
it("img: media + colorGrading", () => {
const s = resolveEditingAffordances(baseFacts({ tag: "img" })).sections;
expect(s).toMatchObject({ media: true, colorGrading: true });
});
it("editable text on a plain element: text section", () => {
const s = resolveEditingAffordances(baseFacts({ hasEditableText: true })).sections;
expect(s.text).toBe(true);
});
it("text section suppressed on host / locked", () => {
expect(
resolveEditingAffordances(baseFacts({ hasEditableText: true, isCompositionHost: true }))
.sections.text,
).toBe(false);
expect(
resolveEditingAffordances(
baseFacts({ hasEditableText: true, isInsideLockedComposition: true }),
).sections.text,
).toBe(false);
});
it("data-start drives timing; animations drive timing + animation", () => {
expect(resolveEditingAffordances(baseFacts({ hasTimingStart: true })).sections.timing).toBe(
true,
);
const anim = resolveEditingAffordances(baseFacts({ animationCount: 2 })).sections;
expect(anim).toMatchObject({ timing: true, animation: true });
});
});
describe("resolveEditingSections (sections-only export)", () => {
it("matches resolveEditingAffordances().sections for the same facts", () => {
const facts = baseFacts({ tag: "video", hasEditableText: true, animationCount: 1 });
expect(resolveEditingSections(facts)).toEqual(resolveEditingAffordances(facts).sections);
});
it("animationCount > 0 turns on timing + animation even without data-start", () => {
const s = resolveEditingSections(baseFacts({ hasTimingStart: false, animationCount: 3 }));
expect(s).toMatchObject({ timing: true, animation: true });
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* Pure, DOM-free editing-affordance resolution. Single source of truth for what
* the studio's edit panel (and any SDK consumer) surfaces per selected element:
* capability flags + which section types apply. No getComputedStyle, no DOM —
* the caller supplies normalized facts (live or static). See the SDK adapter
* (browser-only) and the studio mapper for the two fact extractors.
*/
export interface DomEditCapabilities {
canSelect: boolean;
canEditStyles: boolean;
/** Can take a non-destructive `clip-path: inset()` crop. Broader than
* canEditStyles: a sub-composition host can be cropped from the parent view
* (the crop is a viewport clip that persists on the host in the parent
* source), even though its internal styles are edited by drilling in. */
canCrop: boolean;
/** Directly editable authored left/top style fields. Canvas drag uses manual edits instead. */
canMove: boolean;
/** Directly editable authored width/height style fields. Canvas resize uses manual edits instead. */
canResize: boolean;
canApplyManualOffset: boolean;
canApplyManualSize: boolean;
canApplyManualRotation: boolean;
reasonIfDisabled?: string;
}
export interface EditingSectionApplicability {
text: boolean;
media: boolean;
/** Element-level only — the consumer still ANDs its own feature flag. */
colorGrading: boolean;
timing: boolean;
animation: boolean;
}
export interface EditingAffordances {
capabilities: DomEditCapabilities;
sections: EditingSectionApplicability;
}
export interface EditableElementFacts {
/** A stable patch target exists (selector|hfId in studio; always true in the SDK model). */
hasStableTarget: boolean;
/** Lowercased tag name. */
tag: string;
/** kebab-case. Capability logic reads left/top/width/height/transform; sections read nothing here. */
inlineStyles: Record<string, string>;
/** kebab-case. Absent => canMove/canResize default to false (no live layout). */
computedStyles?: Record<string, string>;
isCompositionHost: boolean;
isCompositionRoot: boolean;
isInsideLockedComposition: boolean;
isMasterView: boolean;
existsInSource: boolean;
/** studio: textFields.length > 0 ; SDK: model.text != null */
hasEditableText: boolean;
/** data-start present on the element */
hasTimingStart: boolean;
/** count of GSAP tweens targeting this element */
animationCount: number;
}
/**
* kebab-case px parser. Single source of truth — studio's domEditingDom
* re-exports this so the two paths can't drift.
*/
export function parsePx(value: string | undefined): number | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed.endsWith("px")) return null;
const parsed = parseFloat(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
/** Whether a CSS transform is the identity (matrix or matrix3d). Core-internal. */
// fallow-ignore-next-line complexity
function isIdentityTransform(value: string | undefined): boolean {
const transform = (value ?? "none").trim();
if (!transform || transform === "none") return true;
const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
if (matrix && matrix[1]) {
const parts = matrix[1].split(",");
if (parts.length !== 6) return false;
const values = parts.map((part) => Number.parseFloat(part.trim()));
if (values.some((part) => !Number.isFinite(part))) return false;
const [a = 0, b = 0, c = 0, d = 0, e = 0, f = 0] = values;
return (
Math.abs(a - 1) < 0.0001 &&
Math.abs(b) < 0.0001 &&
Math.abs(c) < 0.0001 &&
Math.abs(d - 1) < 0.0001 &&
Math.abs(e) < 0.0001 &&
Math.abs(f) < 0.0001
);
}
const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
if (!matrix3d || !matrix3d[1]) return false;
const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
return values.every((part, index) => Math.abs(part - (identity[index] ?? 0)) < 0.0001);
}
// fallow-ignore-next-line complexity
function resolveCapabilities(facts: EditableElementFacts): DomEditCapabilities {
if (!facts.hasStableTarget || facts.isInsideLockedComposition) {
return {
canSelect: !facts.isInsideLockedComposition,
canEditStyles: false,
canCrop: false,
canMove: false,
canResize: false,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
reasonIfDisabled: facts.isInsideLockedComposition
? "This element belongs to a locked composition."
: "Studio could not resolve a stable patch target for this element.",
};
}
if (!facts.existsInSource) {
return {
canSelect: true,
canEditStyles: false,
canCrop: false,
canMove: false,
canResize: false,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
reasonIfDisabled: "This element is generated by a script and cannot be edited visually.",
};
}
if (facts.isCompositionRoot) {
return {
canSelect: true,
canEditStyles: true,
canCrop: false, // the root defines the canvas/preview bounds — nothing to crop against
canMove: false,
canResize: false,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
reasonIfDisabled: "The root composition defines the preview bounds.",
};
}
const computed = facts.computedStyles ?? {};
const position = computed.position;
const left = parsePx(facts.inlineStyles.left) ?? parsePx(computed.left);
const top = parsePx(facts.inlineStyles.top) ?? parsePx(computed.top);
const width = parsePx(facts.inlineStyles.width) ?? parsePx(computed.width);
const height = parsePx(facts.inlineStyles.height) ?? parsePx(computed.height);
const hasTransformDrivenGeometry = !isIdentityTransform(computed.transform);
const canMove =
(position === "absolute" || position === "fixed") &&
left != null &&
top != null &&
!hasTransformDrivenGeometry;
const canResize = canMove && (width != null || height != null);
const canApplyManualGeometry = !facts.isCompositionHost;
const reasonIfDisabled = canApplyManualGeometry
? undefined
: "Select an internal layer to transform it.";
const canEditStyles = !(facts.isCompositionHost && facts.isMasterView);
// Crop is broader than style editing: a sub-composition host CAN be cropped
// from the parent view (a viewport clip persisted on the host in the parent
// source), even though its internal styles are edited by drilling in.
const canCrop = true;
return {
canSelect: true,
canEditStyles,
canCrop,
canMove,
canResize,
canApplyManualOffset: canApplyManualGeometry,
canApplyManualSize: canApplyManualGeometry,
canApplyManualRotation: canApplyManualGeometry,
reasonIfDisabled,
};
}
/**
* Section applicability only. Reads no style facts, so callers that already
* hold resolved capabilities (e.g. the studio panel) can compute sections
* without re-running the capability geometry parse.
*/
export function resolveEditingSections(facts: EditableElementFacts): EditingSectionApplicability {
return {
text: facts.hasEditableText && !facts.isCompositionHost && !facts.isInsideLockedComposition,
media: facts.tag === "video" || facts.tag === "audio" || facts.tag === "img",
colorGrading: facts.tag === "video" || facts.tag === "img",
timing: facts.hasTimingStart || facts.animationCount > 0,
animation: facts.animationCount > 0,
};
}
export function resolveEditingAffordances(facts: EditableElementFacts): EditingAffordances {
return { capabilities: resolveCapabilities(facts), sections: resolveEditingSections(facts) };
}
@@ -0,0 +1,28 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { buildAssetSnippet } from "./assetSnippet";
import type { FigmaManifestRecord } from "./types";
const record: FigmaManifestRecord = {
id: "image_001",
type: "image",
path: ".media/images/image_001.png",
source: "figma",
description: 'Hero "banner"',
width: 240,
height: 57,
provenance: { source: "figma", fileKey: "FK", nodeId: "92:573", format: "png" },
};
describe("buildAssetSnippet", () => {
it("emits an img tag with src, dims, escaped alt, and data-figma-id", () => {
const { path, html } = buildAssetSnippet(record);
expect(path).toBe(".media/images/image_001.png");
expect(html).toContain('src=".media/images/image_001.png"');
expect(html).toContain('width="240"');
expect(html).toContain('height="57"');
expect(html).toContain('data-figma-id="92:573"');
expect(html).toContain("&quot;banner&quot;");
expect(html).not.toContain('"banner"');
});
});
+20
View File
@@ -0,0 +1,20 @@
import type { AssetSnippet, FigmaManifestRecord } from "./types";
function escapeAttr(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function buildAssetSnippet(record: FigmaManifestRecord): AssetSnippet {
// Every interpolated attribute is escaped — path and nodeId are
// system-generated today, but defense-in-depth is free here.
const alt = escapeAttr(record.description ?? record.id);
const src = escapeAttr(record.path);
const w = record.width !== undefined ? ` width="${record.width}"` : "";
const h = record.height !== undefined ? ` height="${record.height}"` : "";
const html = `<img src="${src}" alt="${alt}"${w}${h} data-figma-id="${escapeAttr(record.provenance.nodeId)}" />`;
return { path: record.path, html };
}
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment node
import { describe, expect, it, afterEach, beforeEach } from "vitest";
import { appendFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
appendBinding,
upsertBindings,
findBindingByFigmaId,
readBindings,
readLibraryMap,
recordLibraryFile,
type FigmaBindingRecord,
} from "./bindings";
let dir = "";
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "hf-bindings-"));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
const REC: FigmaBindingRecord = {
kind: "binding",
figmaId: "VariableID:1:23",
key: "abc123",
sourceFileKey: "FILE",
compositionVariableId: "figma:Blue/500",
version: "7",
};
describe("bindings index", () => {
it("round-trips binding records through .media/figma-bindings.jsonl", () => {
expect(readBindings(dir)).toEqual([]);
appendBinding(dir, REC);
appendBinding(dir, {
...REC,
figmaId: "VariableID:1:24",
compositionVariableId: "figma:Red/500",
});
const all = readBindings(dir);
expect(all).toHaveLength(2);
expect(all[0]?.compositionVariableId).toBe("figma:Blue/500");
});
it("findBindingByFigmaId matches exact ids only — never values or names", () => {
appendBinding(dir, REC);
expect(findBindingByFigmaId(dir, "VariableID:1:23")?.key).toBe("abc123");
expect(findBindingByFigmaId(dir, "VariableID:1:99")).toBeNull();
expect(findBindingByFigmaId(dir, "Blue/500")).toBeNull();
});
it("matches alias-chain members too (semantic id bound, primitive in chain)", () => {
appendBinding(dir, { ...REC, aliasChain: ["VariableID:9:1", "VariableID:1:23"] });
expect(findBindingByFigmaId(dir, "VariableID:9:1")?.compositionVariableId).toBe(
"figma:Blue/500",
);
});
it("persists answered library-file mappings (asked once per project)", () => {
expect(readLibraryMap(dir)).toEqual({});
recordLibraryFile(dir, "libkey-1", "LIBFILE");
expect(readLibraryMap(dir)).toEqual({ "libkey-1": "LIBFILE" });
});
it("skips malformed lines instead of crashing", () => {
appendBinding(dir, REC);
appendFileSync(join(dir, ".media", "figma-bindings.jsonl"), "not json\n");
expect(readBindings(dir)).toHaveLength(1);
});
it("upsert replaces stale rows for re-imported figmaIds, keeps others + library rows", () => {
appendBinding(dir, REC);
appendBinding(dir, { ...REC, figmaId: "VariableID:2:2", compositionVariableId: "figma:Red" });
recordLibraryFile(dir, "libkey-2", "LIB2");
upsertBindings(dir, [{ ...REC, compositionVariableId: "figma:Blue/500-v2", version: "9" }]);
const all = readBindings(dir);
expect(all).toHaveLength(2);
expect(findBindingByFigmaId(dir, REC.figmaId)?.compositionVariableId).toBe("figma:Blue/500-v2");
expect(findBindingByFigmaId(dir, "VariableID:2:2")?.compositionVariableId).toBe("figma:Red");
expect(readLibraryMap(dir)["libkey-2"]).toBe("LIB2");
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Binding index — the machine-readable join between figma variable/style
* identities and composition variables (design spec §7.1).
*
* Lives at .media/figma-bindings.jsonl, next to (but separate from) the
* human-readable figma-tokens.json sidecar. Resolution is exact-ID only:
* a missed link bakes a correct literal; a wrong link silently changes
* color at the next brand refresh. Never match by value or name.
*/
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { readJsonlValues } from "./jsonl";
import { mediaDir } from "./manifest";
const BINDINGS_FILE = "figma-bindings.jsonl";
export interface FigmaBindingRecord {
kind: "binding";
/** the figma variable/style id as it appears in node data (exact match key) */
figmaId: string;
/** stable cross-file identity, when known ("id and key are stable over the lifetime") */
key?: string;
sourceFileKey: string;
/** semantic→primitive alias chain, directly-bound id first */
aliasChain?: string[];
compositionVariableId: string;
brandRole?: string;
/** figma file version at import time — staleness check */
version: string;
}
interface LibraryRecord {
kind: "library";
libraryKey: string;
fileKey: string;
}
function bindingsPath(projectDir: string): string {
return join(mediaDir(projectDir), BINDINGS_FILE);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isBindingRecord(value: unknown): value is FigmaBindingRecord {
return (
isRecord(value) &&
value.kind === "binding" &&
typeof value.figmaId === "string" &&
typeof value.sourceFileKey === "string" &&
typeof value.compositionVariableId === "string" &&
typeof value.version === "string"
);
}
function isLibraryRecord(value: unknown): value is LibraryRecord {
return (
isRecord(value) &&
value.kind === "library" &&
typeof value.libraryKey === "string" &&
typeof value.fileKey === "string"
);
}
function readLines(projectDir: string): unknown[] {
return readJsonlValues(bindingsPath(projectDir));
}
function appendLine(projectDir: string, record: unknown): void {
mkdirSync(mediaDir(projectDir), { recursive: true });
appendFileSync(bindingsPath(projectDir), JSON.stringify(record) + "\n");
}
export function readBindings(projectDir: string): FigmaBindingRecord[] {
return readLines(projectDir).filter(isBindingRecord);
}
export function appendBinding(projectDir: string, record: FigmaBindingRecord): void {
appendLine(projectDir, record);
}
/**
* Upsert by figmaId: re-running a tokens import must REPLACE that file's
* stale binding rows, not append duplicates — `findBindingByFigmaId`
* returns the first match, so appended duplicates would pin lookups to the
* stale record forever. Library rows and other files' bindings survive.
*/
export function upsertBindings(projectDir: string, records: FigmaBindingRecord[]): void {
const incoming = new Set(records.map((r) => r.figmaId));
const survivors = readLines(projectDir).filter(
(line) => !(isBindingRecord(line) && incoming.has(line.figmaId)),
);
mkdirSync(mediaDir(projectDir), { recursive: true });
const lines = [...survivors, ...records].map((r) => JSON.stringify(r)).join("\n");
writeFileSync(bindingsPath(projectDir), lines.length > 0 ? lines + "\n" : "");
}
/** Exact-ID lookup, checking alias chains too. Never value/name matching. */
export function findBindingByFigmaId(
projectDir: string,
figmaId: string,
): FigmaBindingRecord | null {
for (const b of readBindings(projectDir)) {
if (b.figmaId === figmaId) return b;
if (b.aliasChain?.includes(figmaId)) return b;
}
return null;
}
/** Answered "which file is this library?" mappings — asked once per project. */
export function readLibraryMap(projectDir: string): Record<string, string> {
const map: Record<string, string> = {};
for (const r of readLines(projectDir)) {
if (isLibraryRecord(r)) map[r.libraryKey] = r.fileKey;
}
return map;
}
export function recordLibraryFile(projectDir: string, libraryKey: string, fileKey: string): void {
const record: LibraryRecord = { kind: "library", libraryKey, fileKey };
appendLine(projectDir, record);
}
+362
View File
@@ -0,0 +1,362 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { createFigmaClient, FigmaClientError, type FigmaFetch } from "./client";
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function fetchStub(handler: (url: string) => Response): { fetch: FigmaFetch; calls: string[] } {
const calls: string[] = [];
const fetch: FigmaFetch = (url, init) => {
calls.push(`${url}|${JSON.stringify(init?.headers ?? {})}`);
return Promise.resolve(handler(url));
};
return { fetch, calls };
}
describe("createFigmaClient", () => {
it("throws NO_TOKEN when token is missing or blank", () => {
expect(() => createFigmaClient({ token: "" })).toThrowError(
expect.objectContaining({ code: "NO_TOKEN" }),
);
expect(() => createFigmaClient({ token: " " })).toThrowError(
expect.objectContaining({ code: "NO_TOKEN" }),
);
});
it("sends the token as X-Figma-Token on every request", async () => {
const { fetch, calls } = fetchStub(() =>
jsonResponse(200, { images: { "1:2": "https://cdn.example/a.png" } }),
);
const client = createFigmaClient({ token: "tok-1", fetch });
await client.renderNode({ fileKey: "F", nodeId: "1:2" }, { format: "png" });
expect(calls[0]).toContain('"X-Figma-Token":"tok-1"');
});
});
describe("renderNode", () => {
it("calls /v1/images/:key with ids/format/scale and returns the render url", async () => {
const { fetch, calls } = fetchStub(() =>
jsonResponse(200, { images: { "1:2": "https://cdn.example/a.png" } }),
);
const client = createFigmaClient({ token: "t", fetch });
const out = await client.renderNode(
{ fileKey: "FILE", nodeId: "1:2" },
{ format: "png", scale: 2 },
);
expect(out.url).toBe("https://cdn.example/a.png");
expect(out.ext).toBe("png");
expect(calls[0]).toContain("/v1/images/FILE?ids=1%3A2&format=png&scale=2");
});
it("throws when the ref has no nodeId", async () => {
const client = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(200, {})).fetch,
});
await expect(client.renderNode({ fileKey: "F" }, { format: "png" })).rejects.toThrowError(
/nodeId/,
);
});
it("throws RENDER_FAILED when figma returns a null render", async () => {
const { fetch } = fetchStub(() => jsonResponse(200, { images: { "1:2": null } }));
const client = createFigmaClient({ token: "t", fetch });
await expect(
client.renderNode({ fileKey: "F", nodeId: "1:2" }, { format: "svg" }),
).rejects.toThrowError(expect.objectContaining({ code: "RENDER_FAILED" }));
});
});
describe("imageFills", () => {
it("returns the imageRef->url map from /v1/files/:key/images", async () => {
const { fetch } = fetchStub(() =>
jsonResponse(200, { meta: { images: { refA: "https://cdn/x" } } }),
);
const client = createFigmaClient({ token: "t", fetch });
const fills = await client.imageFills("FILE");
expect(fills.get("refA")).toBe("https://cdn/x");
});
});
describe("variables", () => {
it("maps HTTP 403 to REQUIRES_ENTERPRISE", async () => {
const { fetch } = fetchStub(() => jsonResponse(403, { message: "nope" }));
const client = createFigmaClient({ token: "t", fetch });
await expect(client.variables("FILE")).rejects.toThrowError(
expect.objectContaining({ code: "REQUIRES_ENTERPRISE" }),
);
});
it("returns meta payload on success", async () => {
const { fetch, calls } = fetchStub(() =>
jsonResponse(200, {
meta: { variables: { "VariableID:1:2": { name: "Blue/500" } }, variableCollections: {} },
}),
);
const client = createFigmaClient({ token: "t", fetch });
const out = await client.variables("FILE");
expect(out.variables["VariableID:1:2"]?.name).toBe("Blue/500");
expect(calls[0]).toContain("/v1/files/FILE/variables/local");
});
});
describe("error mapping", () => {
it("maps 429 to RATE_LIMITED (after retries) and 401 to BAD_TOKEN", async () => {
const stub = fetchStub(() => jsonResponse(429, {}));
const c429 = createFigmaClient({
token: "t",
fetch: stub.fetch,
sleep: () => Promise.resolve(),
});
await expect(c429.styles("F")).rejects.toThrowError(
expect.objectContaining({ code: "RATE_LIMITED" }),
);
// 1 initial + 3 retries = 4 attempts
expect(stub.calls).toHaveLength(4);
const c401 = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(401, {})).fetch,
});
await expect(c401.styles("F")).rejects.toThrowError(
expect.objectContaining({ code: "BAD_TOKEN" }),
);
});
it("retries 429 and succeeds when the limit clears", async () => {
let n = 0;
const waits: number[] = [];
const client = createFigmaClient({
token: "t",
fetch: (() => {
n += 1;
return Promise.resolve(
n < 3
? jsonResponse(429, {})
: jsonResponse(200, {
meta: { styles: [{ key: "k", name: "P", style_type: "FILL" }] },
}),
);
}) as FigmaFetch,
sleep: (ms) => {
waits.push(ms);
return Promise.resolve();
},
});
const styles = await client.styles("F");
expect(styles[0]?.key).toBe("k");
expect(n).toBe(3); // two 429s then success
expect(waits).toEqual([1000, 2000]); // exponential backoff
});
it("caps an oversized Retry-After at 60s so the CLI can't block for an hour", async () => {
let n = 0;
const waits: number[] = [];
const client = createFigmaClient({
token: "t",
fetch: (() => {
n += 1;
return Promise.resolve(
n === 1
? new Response("{}", { status: 429, headers: { "retry-after": "3600" } })
: jsonResponse(200, { meta: { styles: [] } }),
);
}) as FigmaFetch,
sleep: (ms) => {
waits.push(ms);
return Promise.resolve();
},
});
await client.styles("F");
expect(waits).toEqual([60_000]); // 3600s clamped, not 3_600_000
});
it("retries 429 on non-styles endpoints too (retry lives in the shared get)", async () => {
let n = 0;
const client = createFigmaClient({
token: "t",
fetch: (() => {
n += 1;
return Promise.resolve(
n < 2
? jsonResponse(429, {})
: jsonResponse(200, { images: { "1:2": "https://cdn/a.png" } }),
);
}) as FigmaFetch,
sleep: () => Promise.resolve(),
});
const out = await client.renderNodes("F", ["1:2"], { format: "png" });
expect(out[0]?.url).toBe("https://cdn/a.png");
expect(n).toBe(2); // one 429 then success
});
it("honors Retry-After (seconds) over the backoff default", async () => {
let n = 0;
const waits: number[] = [];
const client = createFigmaClient({
token: "t",
fetch: (() => {
n += 1;
return Promise.resolve(
n === 1
? new Response("{}", { status: 429, headers: { "retry-after": "5" } })
: jsonResponse(200, { meta: { styles: [] } }),
);
}) as FigmaFetch,
sleep: (ms) => {
waits.push(ms);
return Promise.resolve();
},
});
await client.styles("F");
expect(waits).toEqual([5000]);
});
it("names the endpoint scope in the styles 403 when the body is silent", async () => {
const client = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(403, { message: "no" })).fetch,
});
await expect(client.styles("F")).rejects.toThrowError(
expect.objectContaining({
code: "FORBIDDEN",
message: expect.stringContaining("library_content:read"),
}),
);
});
it("surfaces figma's own scope diagnosis verbatim from the 403 body (err field)", async () => {
const client = createFigmaClient({
token: "t",
fetch: fetchStub(() =>
jsonResponse(403, {
err: "Invalid scope(s): file_content:read, file_metadata:read. This endpoint requires the library_content:read scope",
}),
).fetch,
});
await expect(client.styles("F")).rejects.toThrowError(
expect.objectContaining({
code: "FORBIDDEN",
message: expect.stringContaining("requires the library_content:read scope"),
}),
);
});
it("reclassifies a 403 'Invalid token' body as BAD_TOKEN, not a scope problem", async () => {
// figma returns 403 (not 401) for bad PATs on file endpoints — verified live
const client = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(403, { err: "Invalid token" })).fetch,
});
const err = await client.styles("F").catch((e: unknown) => e);
expect(err).toBeInstanceOf(FigmaClientError);
if (err instanceof FigmaClientError) {
expect(err.code).toBe("BAD_TOKEN");
expect(err.message).toContain("Re-mint");
}
});
it("keeps REQUIRES_ENTERPRISE for a scopeless variables 403", async () => {
const client = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(403, { message: "no" })).fetch,
});
await expect(client.variables("F")).rejects.toThrowError(
expect.objectContaining({ code: "REQUIRES_ENTERPRISE" }),
);
});
});
describe("renderNodes (batch)", () => {
it("fetches many nodes in ONE /v1/images call and maps each url", async () => {
const stub = fetchStub(() =>
jsonResponse(200, {
images: { "1:2": "https://cdn/a.png", "3:4": "https://cdn/b.png" },
}),
);
const client = createFigmaClient({ token: "t", fetch: stub.fetch });
const out = await client.renderNodes("F", ["1:2", "3:4"], { format: "png" });
expect(stub.calls).toHaveLength(1);
expect(stub.calls[0]).toContain("ids=1%3A2%2C3%3A4"); // "1:2,3:4" url-encoded
expect(out).toEqual([
{ nodeId: "1:2", url: "https://cdn/a.png", ext: "png" },
{ nodeId: "3:4", url: "https://cdn/b.png", ext: "png" },
]);
});
it("returns url:null for a node figma couldn't render, without failing the batch", async () => {
const client = createFigmaClient({
token: "t",
fetch: fetchStub(() =>
jsonResponse(200, { images: { "1:2": "https://cdn/a.png", "3:4": null } }),
).fetch,
});
const out = await client.renderNodes("F", ["1:2", "3:4"], { format: "svg" });
expect(out[0]?.url).toBe("https://cdn/a.png");
expect(out[1]?.url).toBeNull();
});
it("wraps other failures as HTTP_ERROR with status", async () => {
const client = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(500, {})).fetch,
});
const err = await client.nodeTree({ fileKey: "F", nodeId: "1:2" }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(FigmaClientError);
if (err instanceof FigmaClientError) {
expect(err.code).toBe("HTTP_ERROR");
expect(err.status).toBe(500);
}
});
});
describe("nodeTree", () => {
it("requests geometry=paths and returns the node document", async () => {
const { fetch, calls } = fetchStub(() =>
jsonResponse(200, {
nodes: { "1:2": { document: { id: "1:2", name: "Hero", type: "FRAME" } } },
}),
);
const client = createFigmaClient({ token: "t", fetch });
const node = await client.nodeTree({ fileKey: "F", nodeId: "1:2" });
expect(node.name).toBe("Hero");
expect(calls[0]).toContain("/v1/files/F/nodes?ids=1%3A2&geometry=paths");
});
it("throws NODE_NOT_FOUND when the id is absent", async () => {
const { fetch } = fetchStub(() => jsonResponse(200, { nodes: {} }));
const client = createFigmaClient({ token: "t", fetch });
await expect(client.nodeTree({ fileKey: "F", nodeId: "9:9" })).rejects.toThrowError(
expect.objectContaining({ code: "NODE_NOT_FOUND" }),
);
});
});
describe("styles", () => {
it("returns published styles list", async () => {
const { fetch } = fetchStub(() =>
jsonResponse(200, {
meta: { styles: [{ key: "k1", name: "Primary", style_type: "FILL" }] },
}),
);
const client = createFigmaClient({ token: "t", fetch });
const styles = await client.styles("F");
expect(styles[0]?.key).toBe("k1");
});
});
describe("fileVersion", () => {
it("returns version + lastModified from file metadata", async () => {
const { fetch, calls } = fetchStub(() =>
jsonResponse(200, { version: "42", lastModified: "2026-07-01T00:00:00Z" }),
);
const client = createFigmaClient({ token: "t", fetch });
const meta = await client.fileVersion("F");
expect(meta.version).toBe("42");
expect(calls[0]).toContain("/v1/files/F?depth=1");
});
});
+397
View File
@@ -0,0 +1,397 @@
import type { FigmaAssetFormat, FigmaRef } from "./types";
/** Typed capability/transport failures per design spec §4.4. */
export type FigmaClientErrorCode =
| "NO_TOKEN"
| "BAD_TOKEN"
| "FORBIDDEN"
| "REQUIRES_ENTERPRISE"
| "RATE_LIMITED"
| "RENDER_FAILED"
| "NODE_NOT_FOUND"
| "HTTP_ERROR";
export class FigmaClientError extends Error {
readonly code: FigmaClientErrorCode;
readonly status?: number;
constructor(code: FigmaClientErrorCode, message: string, status?: number) {
super(message);
this.name = "FigmaClientError";
this.code = code;
this.status = status;
}
}
/** Injectable fetch so tests never touch the network. */
export type FigmaFetch = (
url: string,
init?: { headers?: Record<string, string> },
) => Promise<Response>;
export interface RenderNodeOptions {
format: FigmaAssetFormat;
scale?: number;
}
export interface RenderedNode {
/** short-lived figma CDN url — freeze it immediately */
url: string;
ext: FigmaAssetFormat;
}
export interface FigmaVariablePayload {
name: string;
key?: string;
resolvedType?: string;
valuesByMode?: Record<string, unknown>;
variableCollectionId?: string;
}
export interface FigmaVariablesResult {
variables: Record<string, FigmaVariablePayload>;
variableCollections: Record<string, unknown>;
}
export interface FigmaStyleMeta {
key: string;
name: string;
style_type: string;
node_id?: string;
description?: string;
}
/** Raw figma node document from GET /v1/files/:key/nodes. Field-level shape
* is consumed by nodeToHtml; kept loose here on purpose — consumers narrow
* children/fills/etc themselves. */
export interface FigmaNodeDocument {
id: string;
name: string;
type: string;
[field: string]: unknown;
}
export interface FigmaFileVersion {
version: string;
lastModified: string;
}
/** One batch render result — url is null when figma couldn't render that
* node (a bad node id in the batch shouldn't fail the whole call). */
export interface BatchRenderedNode {
nodeId: string;
url: string | null;
ext: FigmaAssetFormat;
}
export interface FigmaClient {
renderNode(ref: FigmaRef, opts: RenderNodeOptions): Promise<RenderedNode>;
/** Batch render many nodes of ONE file in a single /v1/images call — the
* documented rate-limit workaround (comma-separated ids). Per-node
* failures come back as url:null rather than throwing the batch. */
renderNodes(
fileKey: string,
nodeIds: string[],
opts: RenderNodeOptions,
): Promise<BatchRenderedNode[]>;
imageFills(fileKey: string): Promise<Map<string, string>>;
variables(fileKey: string): Promise<FigmaVariablesResult>;
styles(fileKey: string): Promise<FigmaStyleMeta[]>;
nodeTree(ref: FigmaRef): Promise<FigmaNodeDocument>;
fileVersion(fileKey: string): Promise<FigmaFileVersion>;
}
export interface FigmaClientOptions {
token: string;
fetch?: FigmaFetch;
baseUrl?: string;
/** Injectable delay for 429 backoff — tests pass a no-op so retries don't
* actually wait. Defaults to a real timer. */
sleep?: (ms: number) => Promise<void>;
/** Max 429 retries before giving up. Default 3. */
maxRetries?: number;
}
/** Read scope each endpoint needs, named exactly as figma's PAT settings UI
* lists them — so a 403 tells the user which checkbox to tick, not just
* "some read scope". The styles endpoint's `library_content:read` is the one
* the setup docs used to omit (it 403s even with file content + metadata). */
const SCOPE_HINTS = {
fileContent: "File content: Read-only",
fileMetadata: "File metadata: Read-only",
libraryContent: "Library content: Read-only (library_content:read)",
} as const;
/** Longest we'll auto-wait on a single Retry-After before giving up — past a
* minute the user is better off cancelling and reducing batch size (the
* RATE_LIMITED message says so) than watching the CLI block silently. */
const MAX_RETRY_WAIT_MS = 60_000;
/** Parse a Retry-After header (figma sends integer seconds; the HTTP spec
* also allows a date) into ms, capped at MAX_RETRY_WAIT_MS, or null when
* absent/unparseable. The cap keeps a spec-legal `Retry-After: 3600` (tier
* quota exhaustion) from silently blocking the CLI for an hour. */
function retryAfterMs(res: Response): number | null {
const raw = res.headers.get("retry-after");
if (raw === null) return null;
const secs = Number(raw);
if (Number.isFinite(secs)) return Math.min(MAX_RETRY_WAIT_MS, Math.max(0, secs * 1000));
const date = Date.parse(raw);
if (Number.isNaN(date)) return null;
return Math.min(MAX_RETRY_WAIT_MS, Math.max(0, date - Date.now()));
}
/** Figma's error bodies are precise — "Invalid token", or "Invalid scope(s):
* … requires the X scope" — and worth surfacing verbatim instead of a
* generic guess. The message lives under `err` on most endpoints but
* `message` on /variables; read both. Returns null when unparseable. */
async function readFigmaErrorMessage(res: Response): Promise<string | null> {
let text: string;
try {
text = await res.text();
} catch {
return null;
}
try {
const body: unknown = JSON.parse(text);
if (isRecord(body)) {
if (typeof body.err === "string") return body.err;
if (typeof body.message === "string") return body.message;
}
} catch {
// non-JSON body — fall through
}
return text.trim() === "" ? null : text.trim();
}
function requireNodeId(ref: FigmaRef): string {
if (!ref.nodeId) throw new Error(`figma ref ${ref.fileKey} has no nodeId`);
return ref.nodeId;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function toVariablePayload(payload: unknown): FigmaVariablePayload | null {
if (!isRecord(payload) || typeof payload.name !== "string") return null;
return {
name: payload.name,
key: optionalString(payload.key),
resolvedType: optionalString(payload.resolvedType),
valuesByMode: isRecord(payload.valuesByMode) ? payload.valuesByMode : undefined,
variableCollectionId: optionalString(payload.variableCollectionId),
};
}
export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
const token = options.token.trim();
if (token === "") {
throw new FigmaClientError(
"NO_TOKEN",
[
"FIGMA_TOKEN is missing. One-time setup:",
" 1. figma.com/settings → Security → Personal access tokens → Generate new token",
" 2. Scopes (read-only is all this integration ever needs — it never writes to figma):",
" File content: Read-only · File metadata: Read-only",
" Library content: Read-only (needed for the `tokens` published-styles fallback)",
" Variables: Read-only (optional — brand variables, requires figma Enterprise;",
" without it `tokens` falls back to published styles)",
' 3. export FIGMA_TOKEN="figd_…" — add it to your shell profile or the project .env',
" so future sessions skip this step",
"Then re-run this command.",
].join("\n"),
);
}
const doFetch: FigmaFetch = options.fetch ?? ((url, init) => fetch(url, init));
const base = options.baseUrl ?? "https://api.figma.com";
const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
const maxRetries = options.maxRetries ?? 3;
interface GetOptions {
/** 403 → REQUIRES_ENTERPRISE (variables) rather than FORBIDDEN. */
enterpriseGated?: boolean;
/** scope named in a FORBIDDEN message so the user knows which to add. */
scopeHint?: string;
}
/** Map a 403 to the right typed error using figma's own response body:
* "Invalid token" is a bad PAT (figma returns 403, NOT 401, for these on
* file endpoints), "Invalid scope(s) … requires X" is a missing scope
* surfaced verbatim. Falls back to the endpoint's scopeHint when the body
* is silent. */
function forbiddenError(body: string | null, opts: GetOptions): FigmaClientError {
// Every branch RETURNS the error (the caller throws once) — no mixed
// throw/return, so a future caller that wraps the result gets consistent
// behavior across all three cases.
if (body && /invalid token/i.test(body))
return new FigmaClientError(
"BAD_TOKEN",
"figma rejected the token (403 Invalid token) — it is invalid, expired, or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.",
403,
);
if (opts.enterpriseGated)
return new FigmaClientError(
"REQUIRES_ENTERPRISE",
"figma variables require an Enterprise plan (403) — fall back to styles",
403,
);
if (body && /scope/i.test(body))
return new FigmaClientError(
"FORBIDDEN",
`figma denied access (403): ${body} — add the named scope at figma.com/settings → Security → Personal access tokens.`,
403,
);
const scopeLine = opts.scopeHint
? `This endpoint needs the "${opts.scopeHint}" scope — add it at figma.com/settings → Security → Personal access tokens.`
: "The token is missing a read scope, or your account can't view this file. Check File content: Read-only + File metadata: Read-only at figma.com/settings → Security.";
return new FigmaClientError(
"FORBIDDEN",
`figma denied access (403). ${scopeLine} Also confirm the file is visible to your account.`,
403,
);
}
/** Throw the typed error for a non-ok response (no-op when res.ok). */
async function throwForStatus(res: Response, path: string, opts: GetOptions): Promise<void> {
if (res.ok) return;
if (res.status === 401)
throw new FigmaClientError(
"BAD_TOKEN",
"figma rejected the token (401) — it is expired or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.",
401,
);
if (res.status === 403) throw forbiddenError(await readFigmaErrorMessage(res), opts);
if (res.status === 429)
throw new FigmaClientError(
"RATE_LIMITED",
`figma rate limit hit (429) and still limited after ${maxRetries} retries — wait a minute and re-run, or import fewer nodes per call.`,
429,
);
throw new FigmaClientError(
"HTTP_ERROR",
`figma request failed: HTTP ${res.status} ${path}`,
res.status,
);
}
async function get(path: string, opts: GetOptions = {}): Promise<unknown> {
// Retry 429 with backoff before surfacing RATE_LIMITED — figma's limit is
// per-minute, so a couple of imports in quick succession hit it and a
// short wait clears it. Honor Retry-After when present, else exponential.
let res: Response;
for (let attempt = 0; ; attempt += 1) {
res = await doFetch(`${base}${path}`, { headers: { "X-Figma-Token": token } });
if (res.status !== 429 || attempt >= maxRetries) break;
const wait = retryAfterMs(res) ?? 1000 * 2 ** attempt;
await sleep(wait);
}
await throwForStatus(res, path, opts);
return res.json();
}
return {
async renderNode(ref, opts) {
const nodeId = requireNodeId(ref);
const [result] = await this.renderNodes(ref.fileKey, [nodeId], opts);
if (!result || result.url === null)
throw new FigmaClientError(
"RENDER_FAILED",
`figma could not render node ${nodeId} as ${opts.format}`,
);
return { url: result.url, ext: opts.format };
},
async renderNodes(fileKey, nodeIds, opts) {
if (nodeIds.length === 0) return [];
// /v1/images accepts comma-separated ids — one call for the whole batch,
// which is figma's own answer to the per-minute rate limit.
const params = new URLSearchParams({ ids: nodeIds.join(","), format: opts.format });
if (opts.scale !== undefined) params.set("scale", String(opts.scale));
const body = await get(`/v1/images/${fileKey}?${params}`, {
scopeHint: SCOPE_HINTS.fileContent,
});
const images = isRecord(body) && isRecord(body.images) ? body.images : {};
return nodeIds.map((nodeId) => {
const url = images[nodeId];
return {
nodeId,
url: typeof url === "string" && url !== "" ? url : null,
ext: opts.format,
};
});
},
async imageFills(fileKey) {
const body = await get(`/v1/files/${fileKey}/images`, { scopeHint: SCOPE_HINTS.fileContent });
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
const images = isRecord(meta.images) ? meta.images : {};
const out = new Map<string, string>();
for (const [ref, url] of Object.entries(images)) {
if (typeof url === "string") out.set(ref, url);
}
return out;
},
async variables(fileKey) {
const body = await get(`/v1/files/${fileKey}/variables/local`, { enterpriseGated: true });
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
const variables = isRecord(meta.variables) ? meta.variables : {};
const collections = isRecord(meta.variableCollections) ? meta.variableCollections : {};
const typed: Record<string, FigmaVariablePayload> = {};
for (const [id, payload] of Object.entries(variables)) {
const v = toVariablePayload(payload);
if (v) typed[id] = v;
}
return { variables: typed, variableCollections: collections };
},
async styles(fileKey) {
const body = await get(`/v1/files/${fileKey}/styles`, {
scopeHint: SCOPE_HINTS.libraryContent,
});
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
const styles = Array.isArray(meta.styles) ? meta.styles : [];
return styles.filter(
(s): s is FigmaStyleMeta =>
isRecord(s) &&
typeof s.key === "string" &&
typeof s.name === "string" &&
typeof s.style_type === "string",
);
},
async nodeTree(ref) {
const nodeId = requireNodeId(ref);
const params = new URLSearchParams({ ids: nodeId, geometry: "paths" });
const body = await get(`/v1/files/${ref.fileKey}/nodes?${params}`, {
scopeHint: SCOPE_HINTS.fileContent,
});
const nodes = isRecord(body) && isRecord(body.nodes) ? body.nodes : {};
const entry = nodes[nodeId];
const doc = isRecord(entry) ? entry.document : undefined;
if (
!isRecord(doc) ||
typeof doc.id !== "string" ||
typeof doc.name !== "string" ||
typeof doc.type !== "string"
)
throw new FigmaClientError("NODE_NOT_FOUND", `node ${nodeId} not found in ${ref.fileKey}`);
return { ...doc, id: doc.id, name: doc.name, type: doc.type };
},
async fileVersion(fileKey) {
const body = await get(`/v1/files/${fileKey}?depth=1`, {
scopeHint: SCOPE_HINTS.fileMetadata,
});
const version = isRecord(body) && typeof body.version === "string" ? body.version : "";
const lastModified =
isRecord(body) && typeof body.lastModified === "string" ? body.lastModified : "";
return { version, lastModified };
},
};
}
+21
View File
@@ -0,0 +1,21 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function toHexByte(channel: number): string {
return Math.round(channel * 255)
.toString(16)
.padStart(2, "0")
.toUpperCase();
}
/** figma {r,g,b,a?} floats (0..1) → #RRGGBB, or rgba() when translucent. */
export function figmaColorToCss(value: unknown, extraOpacity = 1): string | null {
if (!isRecord(value)) return null;
const { r, g, b, a } = value;
if (typeof r !== "number" || typeof g !== "number" || typeof b !== "number") return null;
const alpha = (typeof a === "number" ? a : 1) * extraOpacity;
if (alpha >= 1) return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;
const c = (n: number) => Math.round(n * 255);
return `rgba(${c(r)}, ${c(g)}, ${c(b)}, ${Number(alpha.toFixed(4))})`;
}
@@ -0,0 +1,48 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { emitTimelineScript } from "./emitTimelineScript";
import { motionToGsap } from "./motionToGsap";
import type { MotionDoc } from "./types";
const doc: MotionDoc = {
selector: "#hero-headline",
tracks: [
{
property: "opacity",
values: [0, 1, 0],
times: [0, 0.5, 1],
ease: ["linear", [0.539, 0, 0.312, 0.995]],
duration: 2,
repeat: Infinity,
},
],
};
describe("emitTimelineScript", () => {
const script = emitTimelineScript(motionToGsap(doc));
it("creates a paused timeline and never emits repeat:-1", () => {
expect(script).toContain("gsap.timeline({ paused: true })");
expect(script).not.toContain("repeat: -1");
});
it("registers under a string-literal __timelines key", () => {
expect(script).toContain('window.__timelines["figma-hero-headline"] = tl;');
});
it("uses string-literal selectors and sets the initial value", () => {
expect(script).toContain('tl.set("#hero-headline", { opacity: 0 }, 0);');
expect(script).toContain('tl.to("#hero-headline", { keyframes: [');
});
it("registers a CustomEase for the bezier segment", () => {
expect(script).toContain('CustomEase.create("hfCe0", "M0,0 C0.539,0 0.312,0.995 1,1");');
});
});
describe("emitTimelineScript runtime guard", () => {
it("wraps the script in an IIFE that warns when gsap/CustomEase are missing", () => {
const script = emitTimelineScript(motionToGsap(doc));
expect(script).toContain('typeof gsap === "undefined"');
expect(script).toContain("console.warn");
expect(script.startsWith("(function () {")).toBe(true);
expect(script.endsWith("})();")).toBe(true);
});
});
@@ -0,0 +1,53 @@
import type { GsapTween, TimelineSpec } from "./types";
function lit(value: string): string {
return JSON.stringify(value);
}
function num(value: number): number {
return Math.round(value * 1e6) / 1e6;
}
function val(value: number | string): string {
return typeof value === "number" ? String(num(value)) : JSON.stringify(value);
}
function emitTween(t: GsapTween): string[] {
const set = `tl.set(${lit(t.selector)}, { ${t.property}: ${val(t.initial)} }, 0);`;
const kf = t.steps
.map(
(s) =>
`{ ${t.property}: ${val(s.value)}, duration: ${num(s.duration)}, ease: ${lit(s.ease)} }`,
)
.join(", ");
const repeat = t.repeat > 0 ? `, repeat: ${t.repeat}` : "";
return [set, `tl.to(${lit(t.selector)}, { keyframes: [${kf}]${repeat} }, 0);`];
}
export function emitTimelineScript(spec: TimelineSpec): string {
const lines: string[] = [];
// Guard the whole script: if the composition author forgot the GSAP or
// CustomEase CDN tag, warn loudly instead of throwing mid-script and
// silently never registering the timeline.
lines.push("(function () {");
const needsCustomEase = spec.customEases.length > 0;
const missing = needsCustomEase
? 'typeof gsap === "undefined" || typeof CustomEase === "undefined"'
: 'typeof gsap === "undefined"';
const libs = needsCustomEase ? "gsap + CustomEase" : "gsap";
lines.push(
`if (${missing}) { console.warn(${lit(`figma timeline ${spec.timelineId}: ${libs} not loaded — add the CDN <script> tags before this one`)}); return; }`,
);
for (const ce of spec.customEases) {
const [x1, y1, x2, y2] = ce.bezier;
lines.push(
`CustomEase.create(${lit(ce.name)}, "M0,0 C${num(x1)},${num(y1)} ${num(x2)},${num(y2)} 1,1");`,
);
}
lines.push("const tl = gsap.timeline({ paused: true });");
for (const t of spec.tweens) lines.push(...emitTween(t));
lines.push("window.__timelines = window.__timelines || {};");
lines.push(`window.__timelines[${lit(spec.timelineId)}] = tl;`);
lines.push("})();");
return lines.join("\n");
}
+58
View File
@@ -0,0 +1,58 @@
// @vitest-environment node
import { describe, expect, it, afterEach } from "vitest";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { exceedsFreezeCap, freezeBytes, MAX_FREEZE_BYTES } from "./freeze";
const dirs: string[] = [];
function scratch(): string {
const d = mkdtempSync(join(tmpdir(), "hf-freeze-"));
dirs.push(d);
return d;
}
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
describe("exceedsFreezeCap", () => {
it("is false at and under the cap, true just over it", () => {
expect(exceedsFreezeCap(MAX_FREEZE_BYTES)).toBe(false);
expect(exceedsFreezeCap(MAX_FREEZE_BYTES + 1)).toBe(true);
});
});
describe("freezeBytes", () => {
it("writes bytes to a nested path and returns the length", () => {
const dest = join(scratch(), "images", "a.png");
const bytes = new Uint8Array([1, 2, 3, 4]);
expect(freezeBytes(bytes, dest)).toBe(4);
expect(Array.from(readFileSync(dest))).toEqual([1, 2, 3, 4]);
});
it("throws on empty bytes", () => {
expect(() => freezeBytes(new Uint8Array(0), join(scratch(), "x"))).toThrow();
});
});
describe("freezeUrl allowlist", () => {
it("accepts figma + figma-s3 hosts, https only", async () => {
const { isAllowedFreezeUrl } = await import("./freeze");
expect(isAllowedFreezeUrl("https://s3-alpha-sig.figma.com/img/x")).toBe(true);
expect(isAllowedFreezeUrl("https://figma-alpha-api.s3.us-west-2.amazonaws.com/images/x")).toBe(
true,
);
expect(isAllowedFreezeUrl("http://s3-alpha-sig.figma.com/img/x")).toBe(false);
expect(isAllowedFreezeUrl("https://169.254.169.254/latest/meta-data/")).toBe(false);
expect(isAllowedFreezeUrl("https://evilfigma.com/x")).toBe(false);
expect(isAllowedFreezeUrl("file:///etc/passwd")).toBe(false);
expect(isAllowedFreezeUrl("not a url")).toBe(false);
});
it("refuses to freeze from a non-allowlisted url", async () => {
const { freezeUrl } = await import("./freeze");
await expect(freezeUrl("http://localhost:6379/x", "/tmp/never")).rejects.toThrow(
/refusing non-figma url/,
);
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* "Freeze" = write asset bytes to local disk permanently so renders never
* re-fetch from figma (design spec §5) — not Object.freeze.
*/
import { copyFileSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
// ponytail: bound the write so a hostile/runaway source can't fill the disk.
export const MAX_FREEZE_BYTES = 256 * 1024 * 1024;
export function exceedsFreezeCap(byteLength: number): boolean {
return byteLength > MAX_FREEZE_BYTES;
}
export function freezeBytes(bytes: Uint8Array, destPath: string): number {
if (bytes.length === 0) throw new Error("freeze failed: empty bytes");
if (exceedsFreezeCap(bytes.length))
throw new Error(`freeze failed: ${bytes.length} bytes exceeds ${MAX_FREEZE_BYTES} cap`);
mkdirSync(dirname(destPath), { recursive: true });
// Exclusive create; on EEXIST remove and retry — never write through an
// existing file or planted symlink (CodeQL js/insecure-temporary-file).
try {
writeFileSync(destPath, bytes, { flag: "wx" });
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
rmSync(destPath);
writeFileSync(destPath, bytes, { flag: "wx" });
}
return bytes.length;
}
/**
* Only figma-owned hosts may be frozen from a URL — render/CDN responses
* come from figma.com subdomains or figma's S3 buckets. Blocks SSRF via a
* crafted manifest/config URL (metadata endpoints, internal services).
*/
export function isAllowedFreezeUrl(url: string): boolean {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
if (parsed.protocol !== "https:") return false;
const host = parsed.hostname;
return host === "figma.com" || host.endsWith(".figma.com") || host.endsWith(".amazonaws.com");
}
export async function freezeUrl(url: string, destPath: string): Promise<number> {
if (!isAllowedFreezeUrl(url))
throw new Error(`freeze failed: refusing non-figma url ${url} (https + figma hosts only)`);
const res = await fetch(url);
if (!res.ok) throw new Error(`freeze failed: HTTP ${res.status}`);
const declared = Number(res.headers.get("content-length") ?? 0);
if (exceedsFreezeCap(declared))
throw new Error(`freeze failed: content-length ${declared} exceeds ${MAX_FREEZE_BYTES} cap`);
return freezeBytes(new Uint8Array(await res.arrayBuffer()), destPath);
}
export function freezeLocalFile(srcPath: string, destPath: string): void {
const size = statSync(srcPath).size;
if (exceedsFreezeCap(size))
throw new Error(`freeze failed: ${size} bytes exceeds ${MAX_FREEZE_BYTES} cap`);
mkdirSync(dirname(destPath), { recursive: true });
copyFileSync(srcPath, destPath);
}
+9
View File
@@ -0,0 +1,9 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { MAX_FREEZE_BYTES } from "./index";
describe("figma barrel", () => {
it("re-exports the freeze cap constant", () => {
expect(MAX_FREEZE_BYTES).toBe(256 * 1024 * 1024);
});
});
+69
View File
@@ -0,0 +1,69 @@
export type * from "./types";
export { createFigmaClient, FigmaClientError } from "./client";
export type {
FigmaClient,
FigmaClientErrorCode,
FigmaClientOptions,
FigmaFetch,
FigmaFileVersion,
FigmaNodeDocument,
FigmaStyleMeta,
FigmaVariablePayload,
FigmaVariablesResult,
RenderedNode,
RenderNodeOptions,
} from "./client";
export { parseFigmaRef } from "./parseFigmaRef";
export {
MAX_FREEZE_BYTES,
exceedsFreezeCap,
freezeBytes,
freezeUrl,
freezeLocalFile,
} from "./freeze";
export {
mediaDir,
manifestPath,
typeDirPath,
updateRecord,
isFigmaManifestRecord,
readManifest,
appendRecord,
findAllByFigmaNode,
findByFigmaNode,
nextId,
} from "./manifest";
export { regenerateIndex } from "./mediaIndex";
export { buildAssetSnippet } from "./assetSnippet";
export { sanitizeSvg } from "./sanitizeSvg";
export {
appendBinding,
upsertBindings,
findBindingByFigmaId,
readBindings,
readLibraryMap,
recordLibraryFile,
} from "./bindings";
export type { FigmaBindingRecord } from "./bindings";
export { figmaColorToCss } from "./color";
export { resolveBindings } from "./resolveBindings";
export type { BindingSite, ResolvedBindingSite, ResolveBindingsResult } from "./resolveBindings";
export { nodeToHtml, slugify } from "./nodeToHtml";
export type { NodeToHtmlResult, RasterizeRequest } from "./nodeToHtml";
export { tokensToVariables } from "./tokensToVariables";
export type {
CompositionVariableEntry,
FigmaTokenSidecarEntry,
FigmaTokensSidecar,
TokenSource,
TokensToVariablesResult,
} from "./tokensToVariables";
export { mapEase } from "./motionEase";
export {
motionContextToDocs,
type MotionContextResponse,
type MotionContextNode,
type MotionContextToDocsOptions,
} from "./motionContextToDocs";
export { motionToGsap } from "./motionToGsap";
export { emitTimelineScript } from "./emitTimelineScript";
+19
View File
@@ -0,0 +1,19 @@
import { existsSync, readFileSync } from "node:fs";
/** Parse a .jsonl file into values, skipping blank/malformed lines. */
export function readJsonlValues(path: string): unknown[] {
if (!existsSync(path)) return [];
const out: unknown[] = [];
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
try {
out.push(JSON.parse(trimmed));
} catch {
// ponytail: skip malformed lines (partial write from a crash), but say
// so — a silent skip reads as "no record found" downstream.
console.warn(`skipping malformed jsonl line in ${path}: ${trimmed.slice(0, 40)}`);
}
}
return out;
}
+93
View File
@@ -0,0 +1,93 @@
// @vitest-environment node
import { describe, expect, it, afterEach } from "vitest";
import { appendFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { appendRecord, findByFigmaNode, manifestPath, nextId, readManifest } from "./manifest";
import type { FigmaManifestRecord } from "./types";
const dirs: string[] = [];
function project(): string {
const d = mkdtempSync(join(tmpdir(), "hf-manifest-"));
dirs.push(d);
return d;
}
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
function rec(id: string, nodeId: string): FigmaManifestRecord {
return {
id,
type: "image",
path: `.media/images/${id}.png`,
source: "figma",
provenance: { source: "figma", fileKey: "FK", nodeId, format: "png" },
};
}
describe("manifest", () => {
it("appends and reads back records", () => {
const p = project();
appendRecord(p, rec("image_001", "1:2"));
appendRecord(p, rec("image_002", "3:4"));
const all = readManifest(p);
expect(all.map((r) => r.id)).toEqual(["image_001", "image_002"]);
expect(all[1]?.provenance.nodeId).toBe("3:4");
});
it("finds a record by figma node", () => {
const p = project();
appendRecord(p, rec("image_001", "1:2"));
expect(findByFigmaNode(p, "FK", "1:2")?.id).toBe("image_001");
expect(findByFigmaNode(p, "FK", "9:9")).toBeNull();
});
it("allocates incrementing ids per type", () => {
const p = project();
expect(nextId(p, "image")).toBe("image_001");
appendRecord(p, rec("image_001", "1:2"));
expect(nextId(p, "image")).toBe("image_002");
});
it("skips a manifest line that doesn't match the record shape", () => {
const p = project();
appendRecord(p, rec("image_001", "1:2"));
appendFileSync(manifestPath(p), JSON.stringify({ foo: "bar" }) + "\n");
appendRecord(p, rec("image_002", "3:4"));
expect(readManifest(p).map((r) => r.id)).toEqual(["image_001", "image_002"]);
});
it("nextId scans other writers' rows (media-use) so ids never collide", () => {
const p = project();
mkdirSync(join(p, ".media"), { recursive: true });
// media-use shaped row: no provenance.source — fails the figma guard
appendFileSync(
manifestPath(p),
JSON.stringify({
id: "image_007",
type: "image",
path: ".media/images/image_007.png",
source: "unsplash",
provenance: { provider: "unsplash" },
}) + "\n",
);
expect(nextId(p, "image")).toBe("image_008");
});
it("rejects manifest rows with a non image/video type", () => {
const p = project();
mkdirSync(join(p, ".media"), { recursive: true });
appendFileSync(
manifestPath(p),
JSON.stringify({
id: "audio_001",
type: "audio",
path: "x",
source: "figma:F/1",
provenance: { source: "figma", fileKey: "F", nodeId: "1:1" },
}) + "\n",
);
expect(readManifest(p)).toHaveLength(0);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { readJsonlValues } from "./jsonl";
import type { FigmaManifestRecord } from "./types";
const MANIFEST_FILE = "manifest.jsonl";
// Mirrors the media-use `.media/` layout for interop (one shared inventory).
const TYPE_DIRS: Record<FigmaManifestRecord["type"], string> = {
image: "images",
video: "video",
};
export function mediaDir(projectDir: string): string {
return join(projectDir, ".media");
}
export function manifestPath(projectDir: string): string {
return join(mediaDir(projectDir), MANIFEST_FILE);
}
export function typeDirPath(projectDir: string, type: FigmaManifestRecord["type"]): string {
return join(mediaDir(projectDir), TYPE_DIRS[type]);
}
/**
* Guards figma-OWNED rows only — media-use rows (bgm/sfx/icon, no
* provenance.source) legitimately fail this and are filtered out of
* figma's read-view. The shared file is the inventory; each writer
* reads its own rows. `nextId` is the one cross-writer concern and
* scans the full file.
*/
export function isFigmaManifestRecord(value: unknown): value is FigmaManifestRecord {
if (typeof value !== "object" || value === null) return false;
if (!("id" in value) || !("type" in value) || !("path" in value) || !("source" in value))
return false;
if (
typeof value.id !== "string" ||
(value.type !== "image" && value.type !== "video") ||
typeof value.path !== "string"
)
return false;
if (typeof value.source !== "string") return false;
if (!("provenance" in value)) return false;
const provenance = value.provenance;
if (typeof provenance !== "object" || provenance === null) return false;
if (!("source" in provenance) || !("fileKey" in provenance) || !("nodeId" in provenance))
return false;
return (
provenance.source === "figma" &&
typeof provenance.fileKey === "string" &&
typeof provenance.nodeId === "string"
);
}
export function readManifest(projectDir: string): FigmaManifestRecord[] {
return readJsonlValues(manifestPath(projectDir)).filter(isFigmaManifestRecord);
}
export function appendRecord(projectDir: string, record: FigmaManifestRecord): void {
mkdirSync(typeDirPath(projectDir, record.type), { recursive: true });
appendFileSync(manifestPath(projectDir), JSON.stringify(record) + "\n");
}
export function findByFigmaNode(
projectDir: string,
fileKey: string,
nodeId: string,
): FigmaManifestRecord | null {
return findAllByFigmaNode(projectDir, fileKey, nodeId)[0] ?? null;
}
/** EVERY row for a node — reuse gates must check all (format, scale, version)
* tuples, not just the oldest, or a second tuple defeats idempotency forever. */
export function findAllByFigmaNode(
projectDir: string,
fileKey: string,
nodeId: string,
): FigmaManifestRecord[] {
return readManifest(projectDir).filter(
(r) =>
r.provenance.source === "figma" &&
r.provenance.fileKey === fileKey &&
r.provenance.nodeId === nodeId,
);
}
/** Rewrite one row in place by id, preserving every other line (other
* writers' rows included) byte-for-byte. */
export function updateRecord(projectDir: string, record: FigmaManifestRecord): void {
const p = manifestPath(projectDir);
const lines = readFileSync(p, "utf8").split(/\r?\n/);
const out = lines.map((line) => {
const trimmed = line.trim();
if (trimmed.length === 0) return line;
try {
const parsed: unknown = JSON.parse(trimmed);
if (isFigmaManifestRecord(parsed) && parsed.id === record.id) return JSON.stringify(record);
} catch {
// non-JSON line — preserve untouched
}
return line;
});
writeFileSync(p, out.join("\n"));
}
export function nextId(projectDir: string, type: FigmaManifestRecord["type"]): string {
const re = new RegExp(`^${type}_(\\d+)$`);
let max = 0;
// Scan EVERY writer's rows (media-use included), not just figma-owned ones —
// ids and file paths are shared across the inventory, so a figma id minted
// from a figma-only view would collide with media-use's image_NNN files.
const p = manifestPath(projectDir);
if (!existsSync(p)) return `${type}_001`;
for (const line of readFileSync(p, "utf8").split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
continue;
}
if (
typeof parsed !== "object" ||
parsed === null ||
!("id" in parsed) ||
typeof parsed.id !== "string"
)
continue;
const m = parsed.id.match(re);
const n = m?.[1];
if (n !== undefined) max = Math.max(max, Number.parseInt(n, 10));
}
return `${type}_${String(max + 1).padStart(3, "0")}`;
}
+107
View File
@@ -0,0 +1,107 @@
// @vitest-environment node
import { describe, expect, it, afterEach } from "vitest";
import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { execFileSync } from "node:child_process";
import { generateIndexContent, indexPath, regenerateIndex } from "./mediaIndex";
import { manifestPath } from "./manifest";
const dirs: string[] = [];
function project(): string {
const d = mkdtempSync(join(tmpdir(), "hf-media-index-"));
dirs.push(d);
mkdirSync(join(d, ".media"), { recursive: true });
return d;
}
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
const MEDIA_USE_ROW = {
id: "bgm_001",
type: "bgm",
path: ".media/audio/bgm/bgm_001.mp3",
source: "search",
description: "upbeat tech launch",
duration: 25,
provenance: { provider: "heygen-audio", prompt: "upbeat tech launch" },
};
const IMAGE_ROW = {
id: "image_001",
type: "image",
path: ".media/images/image_001.jpg",
source: "search",
description: "gradient tech background",
width: 1920,
height: 1080,
provenance: { provider: "heygen-asset", prompt: "gradient tech background" },
};
const ICON_ROW = {
id: "icon_001",
type: "icon",
path: ".media/images/icon_001.svg",
source: "search",
description: "rocket",
transparent: true,
provenance: { provider: "heygen-asset", prompt: "rocket" },
};
const FIGMA_ROW = {
id: "image_002",
type: "image",
path: ".media/images/image_002.svg",
source: "figma:KEY/1:2",
description: "hero illustration",
entity: "Acme hero",
provenance: { source: "figma", fileKey: "KEY", nodeId: "1:2", version: "9", format: "svg" },
};
// media-use renders every JSON-parseable row, shape or no shape — selection
// parity matters as much as format parity.
const JUNK_ROW = { note: "not a media record" };
const ALL_ROWS = [MEDIA_USE_ROW, IMAGE_ROW, ICON_ROW, FIGMA_ROW, JUNK_ROW];
describe("regenerateIndex", () => {
it("renders every writer's rows (media-use + figma) into one table", () => {
const p = project();
for (const row of ALL_ROWS) appendFileSync(manifestPath(p), JSON.stringify(row) + "\n");
regenerateIndex(p);
const index = readFileSync(indexPath(p), "utf8");
expect(index).toContain("# .media · 5 assets");
expect(index).toContain("25s");
expect(index).toContain("1920×1080");
expect(index).toContain("hero illustration");
});
it("matches media-use's index-gen output byte-for-byte on the same rows", () => {
// Covers duration, width×height, icon+transparent, no-dims, and junk-row
// selection — the full set of branches both generators format.
const ours = generateIndexContent(ALL_ROWS as Record<string, unknown>[]);
// Run the actual media-use generator on identical input. Resolve the
// script relative to THIS file (cwd varies per test runner) and hand it
// over as a file:// URL so the specifier is valid on windows too.
const genUrl = pathToFileURL(
join(
fileURLToPath(new URL(".", import.meta.url)),
"..",
"..",
"..",
"..",
"skills",
"media-use",
"scripts",
"lib",
"index-gen.mjs",
),
).href;
const script = `
import { generateIndexContent } from ${JSON.stringify(genUrl)};
const rows = ${JSON.stringify(ALL_ROWS)};
process.stdout.write(generateIndexContent(rows));
`;
const theirs = execFileSync("node", ["--input-type=module", "-e", script], {
encoding: "utf8",
});
expect(ours).toBe(theirs);
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* Regenerate .media/index.md — the agent-readable inventory table — after a
* figma import, exactly the way media-use does after a resolve. Both writers
* regenerate the SAME file from the full manifest (all writers' rows), so the
* output format AND row selection here must stay byte-identical with
* skills/media-use/scripts/lib/index-gen.mjs — including rendering every
* JSON-parseable row (no shape filtering), or the file would flip-flop
* depending on which writer ran last.
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { readJsonlValues } from "./jsonl";
import { manifestPath, mediaDir } from "./manifest";
type IndexRow = Record<string, unknown>;
function isRow(value: unknown): value is IndexRow {
return typeof value === "object" && value !== null;
}
export function indexPath(projectDir: string): string {
return join(mediaDir(projectDir), "index.md");
}
function pad(str: unknown, len: number): string {
return String(str ?? "").padEnd(len);
}
function formatDur(r: IndexRow): string {
if (r.duration == null) return "—";
return `${String(r.duration)}s`;
}
function formatDims(r: IndexRow): string {
if (r.width && r.height) return `${String(r.width)}×${String(r.height)}`;
if (r.type === "icon" && r.transparent) return "svg";
return "—";
}
function len(value: unknown): number {
return String(value ?? "").length;
}
export function generateIndexContent(records: IndexRow[]): string {
const count = records.length;
const header = `# .media · ${count} asset${count === 1 ? "" : "s"}\n`;
if (count === 0) return header;
const cols = { id: 4, type: 5, dur: 4, dims: 5, path: 5 };
for (const r of records) {
cols.id = Math.max(cols.id, len(r.id));
cols.type = Math.max(cols.type, len(r.type));
cols.dur = Math.max(cols.dur, formatDur(r).length);
cols.dims = Math.max(cols.dims, formatDims(r).length);
cols.path = Math.max(cols.path, len(r.path));
}
const heading =
pad("id", cols.id + 2) +
pad("type", cols.type + 2) +
pad("dur", cols.dur + 2) +
pad("dims", cols.dims + 2) +
pad("path", cols.path + 2) +
"description";
const lines = [header, heading];
for (const r of records) {
lines.push(
pad(r.id, cols.id + 2) +
pad(r.type, cols.type + 2) +
pad(formatDur(r), cols.dur + 2) +
pad(formatDims(r), cols.dims + 2) +
pad(r.path, cols.path + 2) +
String(r.description ?? ""),
);
}
return lines.join("\n") + "\n";
}
/** Rebuild index.md from EVERY writer's manifest rows (media-use + figma). */
export function regenerateIndex(projectDir: string): string {
const records = readJsonlValues(manifestPath(projectDir)).filter(isRow);
const content = generateIndexContent(records);
const p = indexPath(projectDir);
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, content);
return content;
}
@@ -0,0 +1,144 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { motionContextToDocs } from "./motionContextToDocs";
import type { MotionContextResponse } from "./motionContextToDocs";
/**
* Fixture: verbatim `get_motion_context` response for the SDS "Unlocked"
* Motion card (fileKey Hl5L3gkQ3Tz3Y2KTQJbAkT, node 3021:6485, 2026-07-08).
* The expected outputs were validated frame-by-frame against Figma's own
* `export_video` render of the same timeline (verify-motion.mjs PASS).
*/
const FIXTURE: MotionContextResponse = {
nodes: [
{
nodeId: "3021:6487",
nodeName: "Shape w offset",
nodeType: "FRAME",
codeSnippets: {
motionDev:
'<motion.div initial={{ rotate: 0, }} animate={{ rotate: [0, 223.149, 360], }} transition={{ rotate: { duration: 2, times: [0, 0.9999, 1], ease: "linear", repeat: Infinity }, }} />',
},
},
{
nodeId: "3021:6491",
nodeName: "3D Object - Headphones",
nodeType: "ROUNDED_RECTANGLE",
codeSnippets: {
motionDev:
'<motion.div initial={{ y: 0, }} animate={{ y: [0, -71.246, -64.253, -19.227, -1.212, 0], }} transition={{ y: { duration: 2, times: [0, 0.8066, 0.9997, 0.9998, 0.9999, 1], ease: [[0.539, 0, 0.312, 0.995], "linear", "linear", "linear", "linear"], repeat: Infinity }, }} />',
},
},
{
nodeId: "3021:6492",
nodeName: "Shape",
nodeType: "ROUNDED_RECTANGLE",
codeSnippets: {
motionDev:
'<motion.div initial={{ width: 160.5, }} animate={{ width: [160.5, 500, 500, 160.5], }} transition={{ width: { duration: 2, times: [0, 0.1956, 0.9999, 1], ease: [[0.539, 0, 0.312, 0.995], "linear", [0.539, 0, 0.312, 0.995]], repeat: Infinity }, }} />',
},
},
{
nodeId: "3021:6493",
nodeName: "Headline",
nodeType: "TEXT",
codeSnippets: {
motionDev:
'<motion.div initial={{ opacity: 0, x: 98.914, }} animate={{ opacity: [0, 0, 1, 1, 0], x: [98.914, 98.914, 0, 0, 98.914], }} transition={{ opacity: { duration: 2, times: [0, 0.0686, 0.2273, 0.9999, 1], ease: ["linear", [0.539, 0, 0.312, 0.995], "linear", [0.539, 0, 0.312, 0.995]], repeat: Infinity }, x: { duration: 2, times: [0, 0.0686, 0.2273, 0.9999, 1], ease: ["linear", [0.539, 0, 0.312, 0.995], "linear", [0.539, 0, 0.312, 0.995]], repeat: Infinity }, }} />',
},
},
{
nodeId: "3021:6494",
nodeName: "Knob",
nodeType: "FRAME",
codeSnippets: {
motionDev:
'<motion.div initial={{ x: -339.087, }} animate={{ x: [-339.087, 0, 0, -339.087], }} transition={{ x: { duration: 2, times: [0, 0.1956, 0.9999, 1], ease: [[0.539, 0, 0.312, 0.995], "linear", [0.539, 0, 0.312, 0.995]], repeat: Infinity }, }} />',
},
},
],
timelineCohorts: [
{
rootNodeId: "3021:6485",
durationMs: 2000,
loopMode: "loop",
memberNodeIds: ["3021:6487", "3021:6491", "3021:6492", "3021:6493", "3021:6494"],
},
],
};
const SELECTORS: Record<string, string> = {
"3021:6487": "#shape-w-offset",
"3021:6491": "#headphones-3d",
"3021:6492": "#shape-2",
"3021:6493": "#headline",
"3021:6494": "#knob",
};
function docs(repeat = 1) {
return motionContextToDocs(FIXTURE, {
selectorFor: (n) => SELECTORS[n.nodeId] ?? `#${n.nodeId}`,
repeat,
});
}
describe("motionContextToDocs", () => {
it("produces one doc per animated node with caller-supplied selectors", () => {
const out = docs();
expect(out.map((d) => d.selector)).toEqual([
"#shape-w-offset",
"#headphones-3d",
"#shape-2",
"#headline",
"#knob",
]);
});
it("strips the loop-wrap tail and extends the last keyframe to the window end", () => {
const rotation = docs()[0]?.tracks[0];
// [0, 223.149, 360] @ [0, .9999, 1]: the 360 is the wrap marker.
// 223.149° over the 2s window is the true angular speed (the CSS
// snippet's "360° in 2s" disagrees and is wrong — verified against
// export_video ground truth).
expect(rotation?.property).toBe("rotation");
expect(rotation?.values).toEqual([0, 223.149]);
expect(rotation?.times).toEqual([0, 1]);
});
it("strips multi-keyframe wrap clusters but keeps real sub-second motion", () => {
const y = docs()[1]?.tracks[0];
// tail cluster (-19.227, -1.212, 0) spans <1ms each — wrap markers.
// -64.253 @ 0.9997 ends a 0.386s segment — real, kept, extended to 1.
expect(y?.values).toEqual([0, -71.246, -64.253]);
expect(y?.times).toEqual([0, 0.8066, 1]);
});
it("keeps hold segments and drops only the wrap snap", () => {
const width = docs()[2]?.tracks[0];
expect(width?.values).toEqual([160.5, 500, 500]);
expect(width?.times).toEqual([0, 0.1956, 1]);
});
it("parses multiple properties per node", () => {
const headline = docs()[3];
expect(headline?.tracks.map((t) => t.property).sort()).toEqual(["opacity", "x"]);
const opacity = headline?.tracks.find((t) => t.property === "opacity");
expect(opacity?.values).toEqual([0, 0, 1, 1]);
expect(opacity?.times).toEqual([0, 0.0686, 0.2273, 1]);
});
it("preserves bezier easing arrays and applies the requested repeat", () => {
const knob = docs(2)[4]?.tracks[0];
expect(knob?.ease[0]).toEqual([0.539, 0, 0.312, 0.995]);
expect(knob?.repeat).toBe(2);
expect(knob?.duration).toBe(2);
});
it("skips nodes without motion.dev snippets", () => {
const out = motionContextToDocs(
{ nodes: [{ nodeId: "1:1", nodeName: "Static", codeSnippets: { css: "..." } }] },
{ selectorFor: () => "#static" },
);
expect(out).toEqual([]);
});
});
@@ -0,0 +1,229 @@
/**
* Mechanical translation of a raw Figma MCP `get_motion_context` response
* into MotionDocs — no hand transcription (design spec §6 motion notes).
*
* Field-tested decoding rules (2026-07, SDS "Unlocked" card):
*
* - The response carries two encodings per node. The motion.dev snippet is
* the reliable one: every track is sampled inside the timeline-cohort
* window, so values are correct AT their normalized times. The CSS
* snippet stretches per-track durations and can disagree — it is ignored.
* - Keyframes clustered at the tail of the window (segments spanning less
* than WRAP_EPSILON_S of real time) are LOOP-WRAP MARKERS — the instant
* reset at the loop boundary — not authored motion. They are stripped and
* the wrap is realized by the tween's `repeat` restart. Inventing visible
* returns from wrap markers is the known failure mode this module exists
* to prevent.
* - After stripping, the last kept keyframe's time extends to 1 so the
* track fills its window (the dropped tail spanned sub-millisecond time).
*
* Verification is still mandatory: render and compare against
* `export_video` ground truth with skills/figma/scripts/verify-motion.mjs.
*/
import type { MotionDoc, MotionEase, MotionTrack } from "./types";
/** Tail segments shorter than this (seconds) are loop-wrap markers. */
const WRAP_EPSILON_S = 0.005;
export interface MotionContextNode {
nodeId: string;
nodeName: string;
nodeType?: string;
codeSnippets?: { css?: string; motionDev?: string };
}
export interface MotionContextResponse {
nodes: MotionContextNode[];
timelineCohorts?: Array<{
rootNodeId: string;
durationMs: number;
loopMode?: string;
memberNodeIds?: string[];
}>;
}
export interface MotionContextToDocsOptions {
/**
* Maps a node to the CSS selector of its imported element. REQUIRED in
* practice: pass the ids from the Phase-3 component import (the mapper's
* slugs, e.g. `#headphones-3d`) — deriving selectors from node names here
* would silently drift from the imported HTML.
*/
selectorFor: (node: MotionContextNode) => string;
/** Extra plays per track (GSAP semantics: 0 = play once). Default 0. */
repeat?: number;
}
/** motion.dev property → GSAP property. */
const PROPERTY_MAP: Record<string, string> = { rotate: "rotation" };
/** Escape regex metacharacters — keys are `\w+` property names today, but a
* future caller passing anything else must not silently misparse. */
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Extract the balanced `{...}` body following `marker` in `src`. Brace
* counting does not skip string literals — sound for motion.dev output
* (numeric arrays + named eases, never arbitrary strings containing `}`). */
function balancedBlock(src: string, marker: string): string | null {
const at = src.indexOf(marker);
if (at === -1) return null;
const start = src.indexOf("{", at + marker.length - 1);
if (start === -1) return null;
let depth = 0;
for (let i = start; i < src.length; i += 1) {
if (src[i] === "{") depth += 1;
if (src[i] === "}") {
depth -= 1;
if (depth === 0) return src.slice(start + 1, i);
}
}
return null;
}
/** Extract a balanced `[...]` immediately after `key:` inside `src`. */
function arrayAfterKey(src: string, key: string): string | null {
const re = new RegExp(`${escapeRegExp(key)}\\s*:\\s*\\[`);
const m = re.exec(src);
if (!m) return null;
const start = m.index + m[0].length - 1;
let depth = 0;
for (let i = start; i < src.length; i += 1) {
if (src[i] === "[") depth += 1;
if (src[i] === "]") {
depth -= 1;
if (depth === 0) return src.slice(start, i + 1);
}
}
return null;
}
function scalarAfterKey(src: string, key: string): string | null {
const m = new RegExp(`${escapeRegExp(key)}\\s*:\\s*("[^"]*"|[\\w.]+)`).exec(src);
return m?.[1] ?? null;
}
function parseEase(transitionBlock: string): MotionEase[] | null {
const arr = arrayAfterKey(transitionBlock, "ease");
if (arr) return JSON.parse(arr) as MotionEase[];
const scalar = scalarAfterKey(transitionBlock, "ease");
if (scalar?.startsWith('"')) return [JSON.parse(scalar) as string];
return null;
}
/**
* Strip loop-wrap tail keyframes: walking from the end, drop keyframes whose
* incoming segment spans < WRAP_EPSILON_S of real time; stop at the first
* substantial segment. Extend the last kept time to 1.
*/
function stripWrapTail(
values: Array<number | string>,
times: number[],
ease: MotionEase[],
duration: number,
): { values: Array<number | string>; times: number[]; ease: MotionEase[] } {
let end = values.length;
while (end > 2) {
const tPrev = times[end - 2];
const tCur = times[end - 1];
if (tPrev === undefined || tCur === undefined) break;
if ((tCur - tPrev) * duration >= WRAP_EPSILON_S) break;
end -= 1;
}
const v = values.slice(0, end);
const t = times.slice(0, end);
const e = ease.slice(0, Math.max(1, end - 1));
const last = t.length - 1;
if (t[last] !== undefined && t[last] < 1) t[last] = 1;
return { values: v, times: t, ease: e };
}
interface RawTrackData {
values: Array<number | string>;
times: number[];
ease: MotionEase[];
duration: number;
}
/** Extract and validate one property's raw arrays from the snippet blocks. */
function extractTrackData(animate: string, transition: string, prop: string): RawTrackData | null {
const valuesSrc = arrayAfterKey(animate, prop);
const propTransition = balancedBlock(transition, `${prop}: {`);
if (!valuesSrc || !propTransition) return null;
const timesSrc = arrayAfterKey(propTransition, "times");
const durationSrc = scalarAfterKey(propTransition, "duration");
const ease = parseEase(propTransition);
if (!timesSrc || !durationSrc || !ease) return null;
const values = JSON.parse(valuesSrc) as Array<number | string>;
const times = JSON.parse(timesSrc) as number[];
const duration = Number(durationSrc);
if (values.length !== times.length || !Number.isFinite(duration)) return null;
return { values, times, ease, duration };
}
/** Parse one property's track out of the animate/transition blocks. */
function parsePropertyTrack(
animate: string,
transition: string,
prop: string,
repeat: number,
): MotionTrack | null {
const raw = extractTrackData(animate, transition, prop);
if (!raw) return null;
// segment eases: a single named ease applies to every segment
const segCount = raw.values.length - 1;
const segEase =
raw.ease.length === segCount
? raw.ease
: Array.from({ length: segCount }, (_, i) => raw.ease[i % raw.ease.length] ?? "linear");
const stripped = stripWrapTail(raw.values, raw.times, segEase, raw.duration);
return {
property: PROPERTY_MAP[prop] ?? prop,
values: stripped.values,
times: stripped.times,
ease: stripped.ease,
duration: raw.duration,
repeat,
};
}
/** Parse one node's motion.dev snippet into MotionTracks. */
function parseNodeTracks(node: MotionContextNode, repeat: number): MotionTrack[] {
const snippet = node.codeSnippets?.motionDev;
if (!snippet) return [];
const animate = balancedBlock(snippet, "animate={");
const transition = balancedBlock(snippet, "transition={");
if (!animate || !transition) return [];
const tracks: MotionTrack[] = [];
const propRe = /(\w+)\s*:\s*\[/g;
let m: RegExpExecArray | null;
while ((m = propRe.exec(animate)) !== null) {
const prop = m[1];
if (prop === undefined) continue;
const track = parsePropertyTrack(animate, transition, prop, repeat);
if (track) tracks.push(track);
}
return tracks;
}
/**
* Raw `get_motion_context` response → MotionDoc[], mechanically. Feed the
* result to motionToGsap/emitTimelineScript; then verify against
* export_video ground truth before calling the import done.
*/
export function motionContextToDocs(
response: MotionContextResponse,
options: MotionContextToDocsOptions,
): MotionDoc[] {
const repeat = options.repeat ?? 0;
const docs: MotionDoc[] = [];
for (const node of response.nodes ?? []) {
const tracks = parseNodeTracks(node, repeat);
if (tracks.length === 0) continue;
docs.push({ selector: options.selectorFor(node), tracks });
}
return docs;
}
@@ -0,0 +1,45 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { mapEase } from "./motionEase";
describe("mapEase", () => {
it("maps linear to none", () => {
expect(mapEase("linear")).toEqual({ kind: "named", ease: "none" });
});
it("maps a bezier array through unchanged", () => {
expect(mapEase([0.539, 0, 0.312, 0.995])).toEqual({
kind: "bezier",
bezier: [0.539, 0, 0.312, 0.995],
});
});
it("maps named eases to GSAP equivalents (case/format insensitive)", () => {
expect(mapEase("easeOut")).toEqual({ kind: "named", ease: "power2.out" });
expect(mapEase("EASE_IN_AND_OUT")).toEqual({
kind: "named",
ease: "power2.inOut",
});
expect(mapEase("backOut")).toEqual({ kind: "named", ease: "back.out" });
expect(mapEase("HOLD")).toEqual({ kind: "named", ease: "steps(1)" });
});
it("falls back to none for unknown named eases", () => {
expect(mapEase("wobble")).toEqual({ kind: "named", ease: "none" });
});
});
describe("mapEase validation + coverage", () => {
it("rejects malformed bezier arrays (wrong length / NaN) to linear", () => {
expect(mapEase([0.5, 0, 0.3] as unknown as [number, number, number, number])).toEqual({
kind: "named",
ease: "none",
});
expect(mapEase([0.5, Number.NaN, 0.3, 1])).toEqual({ kind: "named", ease: "none" });
});
it("covers circ/expo/bounce/elastic/anticipate/spring", () => {
expect(mapEase("circOut")).toEqual({ kind: "named", ease: "circ.out" });
expect(mapEase("expoInOut")).toEqual({ kind: "named", ease: "expo.inOut" });
expect(mapEase("bounceOut")).toEqual({ kind: "named", ease: "bounce.out" });
expect(mapEase("elasticOut")).toEqual({ kind: "named", ease: "elastic.out" });
expect(mapEase("anticipate")).toEqual({ kind: "named", ease: "back.in" });
expect(mapEase("spring")).toEqual({ kind: "named", ease: "elastic.out" });
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { MappedEase, MotionEase } from "./types";
// Full motion.dev named-ease coverage → nearest GSAP equivalent. Anything
// outside this table falls back to "none" (linear) — documented in the
// /figma skill's motion section so the fallback is never a surprise.
const NAMED_EASE: Record<string, string> = {
linear: "none",
ease: "power1.inOut",
easein: "power2.in",
easeout: "power2.out",
easeinout: "power2.inOut",
easeinandout: "power2.inOut",
backin: "back.in",
backout: "back.out",
backinout: "back.inOut",
backinandout: "back.inOut",
circin: "circ.in",
circout: "circ.out",
circinout: "circ.inOut",
expoin: "expo.in",
expoout: "expo.out",
expoinout: "expo.inOut",
bouncein: "bounce.in",
bounceout: "bounce.out",
bounceinout: "bounce.inOut",
elasticin: "elastic.in",
elasticout: "elastic.out",
elasticinout: "elastic.inOut",
anticipate: "back.in",
spring: "elastic.out",
hold: "steps(1)",
};
function isBezier4(ease: unknown[]): ease is [number, number, number, number] {
return ease.length === 4 && ease.every((n) => typeof n === "number" && Number.isFinite(n));
}
export function mapEase(ease: MotionEase): MappedEase {
if (Array.isArray(ease)) {
// Runtime-validate the 4-tuple: a malformed payload (3 numbers, NaN)
// would otherwise emit a broken CustomEase path that fails at load.
if (isBezier4(ease)) return { kind: "bezier", bezier: ease };
return { kind: "named", ease: "none" };
}
const key = ease.toLowerCase().replace(/[_\s-]/g, "");
return { kind: "named", ease: NAMED_EASE[key] ?? "none" };
}
@@ -0,0 +1,61 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { motionToGsap } from "./motionToGsap";
import type { MotionDoc } from "./types";
const headline: MotionDoc = {
selector: "#hero-headline",
tracks: [
{
property: "opacity",
values: [0, 0, 1, 1, 0],
times: [0, 0.0686, 0.2273, 0.9999, 1],
ease: ["linear", [0.539, 0, 0.312, 0.995], "linear", [0.539, 0, 0.312, 0.995]],
duration: 2,
repeat: Infinity,
},
],
};
describe("motionToGsap", () => {
it("derives a finite, paused-timeline spec from the captured Headline payload", () => {
const spec = motionToGsap(headline);
expect(spec.timelineId).toBe("figma-hero-headline");
expect(spec.tweens).toHaveLength(1);
const t = spec.tweens[0];
expect(t?.selector).toBe("#hero-headline");
expect(t?.property).toBe("opacity");
expect(t?.initial).toBe(0);
// 4 segments for 5 keyframes
expect(t?.steps).toHaveLength(4);
// clamps Infinity -> 0 (single play) for determinism
expect(t?.repeat).toBe(0);
});
it("computes per-segment durations from times * duration", () => {
const t = motionToGsap(headline).tweens[0];
// segment 0: (0.0686 - 0) * 2 = 0.1372
expect(t?.steps[0]?.duration).toBeCloseTo(0.1372, 4);
// segment 1: (0.2273 - 0.0686) * 2 = 0.3174
expect(t?.steps[1]?.duration).toBeCloseTo(0.3174, 4);
});
it("registers a CustomEase per bezier segment and names it in the step", () => {
const spec = motionToGsap(headline);
expect(spec.customEases).toHaveLength(2);
expect(spec.customEases[0]?.bezier).toEqual([0.539, 0, 0.312, 0.995]);
// step 0 ease is linear -> none; step 1 ease is the first bezier
expect(spec.tweens[0]?.steps[0]?.ease).toBe("none");
expect(spec.tweens[0]?.steps[1]?.ease).toBe(spec.customEases[0]?.name);
});
it("throws when times and values lengths disagree", () => {
expect(() =>
motionToGsap({
selector: "#x",
tracks: [{ property: "x", values: [0, 1], times: [0], ease: ["linear"], duration: 1 }],
}),
).toThrow();
});
});
+94
View File
@@ -0,0 +1,94 @@
import { mapEase } from "./motionEase";
import type {
CustomEaseRef,
GsapKeyframeStep,
GsapTween,
MotionDoc,
MotionTrack,
TimelineSpec,
} from "./types";
/**
* repeat semantics match GSAP and motion.dev: count of EXTRA plays
* (0 = play once). Infinity clamps to 0 — a single play — because a
* deterministic render needs a finite timeline; composition-duration-aware
* loop counts are a later milestone (spec §6 motion notes).
*/
function clampRepeat(repeat: number | undefined): number {
return repeat !== undefined && Number.isFinite(repeat) && repeat > 0 ? Math.floor(repeat) : 0;
}
function deriveId(selector: string): string {
const base = selector.replace(/^[#.]/, "").replace(/[^A-Za-z0-9_-]/g, "-");
return `figma-${base.length > 0 ? base : "timeline"}`;
}
/** Mutable counter shared across all tracks so generated CustomEase names stay unique. */
interface CustomEaseCounter {
value: number;
}
/** Resolves one segment's ease, registering a CustomEase in `customEases` for bezier arrays. */
function resolveStepEase(
rawEase: string | [number, number, number, number],
customEases: CustomEaseRef[],
counter: CustomEaseCounter,
): string {
const mapped = mapEase(rawEase);
if (mapped.kind === "bezier") {
const name = `hfCe${counter.value}`;
counter.value += 1;
customEases.push({ name, bezier: mapped.bezier });
return name;
}
return mapped.ease;
}
function buildSteps(
track: MotionTrack,
customEases: CustomEaseRef[],
counter: CustomEaseCounter,
): GsapKeyframeStep[] {
const steps: GsapKeyframeStep[] = [];
for (let i = 1; i < track.values.length; i += 1) {
const tPrev = track.times[i - 1];
const tCur = track.times[i];
const value = track.values[i];
if (tPrev === undefined || tCur === undefined || value === undefined) continue;
const rawEase = track.ease[i - 1] ?? "linear";
const ease = resolveStepEase(rawEase, customEases, counter);
steps.push({ value, duration: (tCur - tPrev) * track.duration, ease });
}
return steps;
}
function buildTween(
track: MotionTrack,
selector: string,
customEases: CustomEaseRef[],
counter: CustomEaseCounter,
): GsapTween {
if (track.values.length < 2 || track.times.length !== track.values.length) {
throw new Error(`motionToGsap: invalid track "${track.property}" (values/times mismatch)`);
}
const initial = track.values[0];
if (initial === undefined) throw new Error(`motionToGsap: empty track "${track.property}"`);
return {
selector,
property: track.property,
initial,
steps: buildSteps(track, customEases, counter),
repeat: clampRepeat(track.repeat),
};
}
export function motionToGsap(doc: MotionDoc): TimelineSpec {
const customEases: CustomEaseRef[] = [];
const counter: CustomEaseCounter = { value: 0 };
const tweens = doc.tracks.map((track) => buildTween(track, doc.selector, customEases, counter));
return { timelineId: deriveId(doc.selector), tweens, customEases };
}
+29
View File
@@ -0,0 +1,29 @@
import type { FigmaNodeDocument } from "./client";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/** Narrow an unknown child entry to a node document, or null. */
function asNodeDocument(value: unknown): FigmaNodeDocument | null {
if (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.type === "string"
) {
return { ...value, id: value.id, name: value.name, type: value.type };
}
return null;
}
/** Typed children of a node document (unknown-shaped entries skipped). */
export function childDocuments(node: FigmaNodeDocument): FigmaNodeDocument[] {
if (!Array.isArray(node.children)) return [];
const out: FigmaNodeDocument[] = [];
for (const child of node.children) {
const doc = asNodeDocument(child);
if (doc) out.push(doc);
}
return out;
}
+388
View File
@@ -0,0 +1,388 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { nodeToHtml } from "./nodeToHtml";
import type { FigmaNodeDocument } from "./client";
const BOX = (x: number, y: number, width: number, height: number) => ({ x, y, width, height });
const SOLID_BLUE = { type: "SOLID", color: { r: 0, g: 0.4, b: 1, a: 1 } };
function frame(children: FigmaNodeDocument[]): FigmaNodeDocument {
return {
id: "1:1",
name: "Hero Card",
type: "FRAME",
absoluteBoundingBox: BOX(100, 200, 800, 600),
fills: [{ type: "SOLID", color: { r: 1, g: 1, b: 1, a: 1 } }],
children,
};
}
describe("nodeToHtml", () => {
it("renders the root frame at exact size with a stable id", () => {
const out = nodeToHtml(frame([]), { resolved: [], unresolved: [] });
expect(out.html).toContain('id="hero-card"');
expect(out.html).toContain('data-figma-id="1:1"');
expect(out.html).toContain("width: 800px");
expect(out.html).toContain("height: 600px");
expect(out.html).toContain("position: relative");
expect(out.html).toContain("background-color: #FFFFFF");
});
it("absolutely positions children relative to the root frame", () => {
const out = nodeToHtml(
frame([
{
id: "1:2",
name: "Badge",
type: "RECTANGLE",
absoluteBoundingBox: BOX(140, 260, 120, 40),
fills: [SOLID_BLUE],
cornerRadius: 8,
opacity: 0.9,
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.html).toContain("left: 40px");
expect(out.html).toContain("top: 60px");
expect(out.html).toContain("width: 120px");
expect(out.html).toContain("border-radius: 8px");
expect(out.html).toContain("opacity: 0.9");
expect(out.html).toContain("background-color: #0066FF");
});
it("positions nested children relative to their PARENT, not the root", () => {
const out = nodeToHtml(
frame([
{
id: "1:2",
name: "Group",
type: "FRAME",
absoluteBoundingBox: BOX(600, 400, 300, 200),
children: [
{
id: "1:3",
name: "Inner",
type: "RECTANGLE",
absoluteBoundingBox: BOX(620, 440, 100, 40),
fills: [SOLID_BLUE],
},
],
},
]),
{ resolved: [], unresolved: [] },
);
// Group at canvas (600,400) inside root (100,200) → left 500, top 200.
expect(out.html).toContain("left: 500px");
expect(out.html).toContain("top: 200px");
// Inner at canvas (620,440) inside Group (600,400) → left 20, top 40 —
// NOT root-relative 520/240, which double-offsets when CSS resolves
// absolute position against the positioned parent.
expect(out.html).toContain("left: 20px");
expect(out.html).toContain("top: 40px");
expect(out.html).not.toContain("left: 520px");
});
it("prefixes digit-leading slugs so ids stay CSS-selector-safe", () => {
const out = nodeToHtml(
frame([
{
id: "1:2",
name: "3D Object - Headphones",
type: "RECTANGLE",
absoluteBoundingBox: BOX(120, 220, 100, 40),
fills: [SOLID_BLUE],
},
]),
{ resolved: [], unresolved: [] },
);
// "#3d-object-headphones" would throw in querySelector/GSAP targeting
expect(out.html).toContain('id="n3d-object-headphones"');
expect(out.html).not.toContain('id="3d-object-headphones"');
});
it("emits text-box-trim for vertically trimmed text (box height < line-height)", () => {
const out = nodeToHtml(
frame([
{
id: "1:2",
name: "Headline",
type: "TEXT",
absoluteBoundingBox: BOX(140, 260, 304, 51),
fills: [SOLID_BLUE],
characters: "Unlocked",
style: { fontFamily: "Inter", fontWeight: 700, fontSize: 70, lineHeightPx: 66.5 },
},
]),
{ resolved: [], unresolved: [] },
);
// figma's trimmed bounds (51px box for a 66.5px line) place cap height at
// the box top; browsers overflow the glyphs below without text-box-trim
expect(out.html).toContain("text-box-trim: trim-both");
expect(out.html).toContain("text-box-edge: cap alphabetic");
});
it("does not trim text whose box matches its line-height", () => {
const out = nodeToHtml(
frame([
{
id: "1:2",
name: "Body",
type: "TEXT",
absoluteBoundingBox: BOX(140, 260, 304, 39),
fills: [SOLID_BLUE],
characters: "Subtitle",
style: { fontFamily: "Inter", fontWeight: 400, fontSize: 32, lineHeightPx: 38.4 },
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.html).not.toContain("text-box-trim");
});
it("emits var() with literal fallback for resolved bindings", () => {
const out = nodeToHtml(
frame([
{
id: "1:2",
name: "Badge",
type: "RECTANGLE",
absoluteBoundingBox: BOX(100, 200, 10, 10),
fills: [SOLID_BLUE],
},
]),
{
resolved: [
{
nodeId: "1:2",
property: "fills",
figmaId: "VariableID:1:1",
compositionVariableId: "figma:Blue/500",
},
],
unresolved: [],
},
);
expect(out.html).toContain("background-color: var(--figma-blue-500, #0066FF)");
});
it("bakes literals and flags unresolved bindings — never a dangling var()", () => {
const out = nodeToHtml(
frame([
{
id: "1:2",
name: "Badge",
type: "RECTANGLE",
absoluteBoundingBox: BOX(100, 200, 10, 10),
fills: [SOLID_BLUE],
},
]),
{
resolved: [],
unresolved: [{ nodeId: "1:2", property: "fills", figmaId: "VariableID:9:9" }],
},
);
expect(out.html).toContain("background-color: #0066FF");
expect(out.html).not.toContain("var(");
expect(out.html).toContain('data-figma-unresolved="fills"');
});
it("renders text with font styles and escaped content", () => {
const out = nodeToHtml(
frame([
{
id: "1:3",
name: "Title",
type: "TEXT",
absoluteBoundingBox: BOX(100, 200, 300, 50),
characters: "Ship <fast> & true",
style: {
fontFamily: "Inter",
fontWeight: 700,
fontSize: 32,
lineHeightPx: 40,
letterSpacing: -0.5,
},
fills: [{ type: "SOLID", color: { r: 0, g: 0, b: 0, a: 1 } }],
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.html).toContain("Ship &lt;fast&gt; &amp; true");
expect(out.html).toContain("font-family: 'Inter'");
expect(out.html).toContain("font-weight: 700");
expect(out.html).toContain("font-size: 32px");
expect(out.html).toContain("line-height: 40px");
expect(out.html).toContain("color: #000000");
});
it("routes vectors to the rasterize list with an img placeholder", () => {
const out = nodeToHtml(
frame([
{
id: "1:4",
name: "Logo Mark",
type: "VECTOR",
absoluteBoundingBox: BOX(120, 220, 64, 64),
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.rasterize).toEqual([{ nodeId: "1:4", name: "Logo Mark", slug: "logo-mark" }]);
expect(out.html).toContain('data-figma-rasterize="1:4"');
expect(out.html).toContain("<img");
});
it("routes IMAGE fills to the rasterize list regardless of node.type", () => {
const out = nodeToHtml(
frame([
{
id: "1:8",
name: "Sneaker Photo",
type: "RECTANGLE",
absoluteBoundingBox: BOX(120, 220, 200, 200),
fills: [{ type: "IMAGE", imageRef: "abc123" }],
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.rasterize).toEqual([
{ nodeId: "1:8", name: "Sneaker Photo", slug: "sneaker-photo" },
]);
expect(out.html).toContain('data-figma-rasterize="1:8"');
expect(out.html).toContain("<img");
});
it("does not double-paint a rasterized node's own fill/corner-radius onto its img", () => {
const out = nodeToHtml(
frame([
{
id: "1:9",
name: "Blob",
type: "VECTOR",
absoluteBoundingBox: BOX(120, 220, 64, 64),
fills: [SOLID_BLUE],
cornerRadius: 12,
opacity: 0.5,
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.html).not.toContain("background-color: #0066FF");
expect(out.html).not.toContain("border-radius: 12px");
// opacity is compositing, not shape — still applies on top of the export
expect(out.html).toContain("opacity: 0.5");
});
it("skips invisible nodes and invisible fills (respects visible:false)", () => {
const out = nodeToHtml(
frame([
{
id: "1:5",
name: "Hidden",
type: "RECTANGLE",
visible: false,
absoluteBoundingBox: BOX(0, 0, 5, 5),
fills: [SOLID_BLUE],
},
{
id: "1:6",
name: "NoFill",
type: "RECTANGLE",
absoluteBoundingBox: BOX(100, 200, 5, 5),
fills: [{ ...SOLID_BLUE, visible: false }],
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.html).not.toContain("1:5");
expect(out.html).toContain('data-figma-id="1:6"');
expect(out.html).not.toContain("background-color: #0066FF");
});
it("maps linear gradients and drop shadows", () => {
const out = nodeToHtml(
frame([
{
id: "1:7",
name: "Grad",
type: "RECTANGLE",
absoluteBoundingBox: BOX(100, 200, 10, 10),
fills: [
{
type: "GRADIENT_LINEAR",
gradientStops: [
{ color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 },
{ color: { r: 0, g: 0, b: 1, a: 1 }, position: 1 },
],
},
],
effects: [
{
type: "DROP_SHADOW",
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 0, y: 4 },
radius: 12,
},
],
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.html).toContain("linear-gradient(180deg, #FF0000 0%, #0000FF 100%)");
expect(out.html).toContain("box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.25)");
});
});
describe("nodeToHtml review fixes", () => {
it("routes TEXT color through the binding-aware path (var() when resolved)", () => {
const text: FigmaNodeDocument = {
id: "2:1",
name: "Title",
type: "TEXT",
absoluteBoundingBox: BOX(0, 0, 100, 20),
fills: [SOLID_BLUE],
characters: "Hi",
};
const { html } = nodeToHtml(frame([text]), {
resolved: [
{ nodeId: "2:1", property: "fills", figmaId: "V:1", compositionVariableId: "figma:Ink" },
],
unresolved: [],
});
expect(html).toContain("color: var(--figma-ink,");
});
it("renders ELLIPSE with border-radius 50%", () => {
const ellipse: FigmaNodeDocument = {
id: "2:2",
name: "Dot",
type: "ELLIPSE",
absoluteBoundingBox: BOX(0, 0, 10, 10),
fills: [SOLID_BLUE],
};
const { html } = nodeToHtml(frame([ellipse]), { resolved: [], unresolved: [] });
expect(html).toContain("border-radius: 50%");
});
it("emits overflow hidden for clipsContent frames", () => {
const clipped = { ...frame([]), clipsContent: true };
const { html } = nodeToHtml(clipped, { resolved: [], unresolved: [] });
expect(html).toContain("overflow: hidden");
});
it("neutralizes a hostile fontFamily instead of breaking out of the style attribute", () => {
const text: FigmaNodeDocument = {
id: "2:3",
name: "Evil",
type: "TEXT",
absoluteBoundingBox: BOX(0, 0, 100, 20),
style: { fontFamily: `Mal"; onerror="alert(1)` },
characters: "x",
};
const { html } = nodeToHtml(frame([text]), { resolved: [], unresolved: [] });
expect(html).not.toContain('onerror="');
expect(html).not.toMatch(/style="[^"]*" onerror/);
});
});
+358
View File
@@ -0,0 +1,358 @@
/**
* Phase 3 mapper: figma node tree → editable HTML with inline styles
* (design spec §7, hybrid fidelity routing).
*
* - Geometry is exact for free: absolute positioning at figma bounds inside
* a fixed-size root — no responsive reflow, no drift.
* - CSS where CSS is faithful: solid/linear-gradient fills, corner radius,
* opacity, drop shadow, blur, text styles.
* - Everything CSS can't match faithfully (vectors, boolean ops, exotic
* paint, IMAGE fills) routes to the rasterize list — the caller exports
* those nodes as images (Phase 1) and fills in the placeholder src. A
* rasterized node's own fill/corner-radius CSS is never emitted — the
* exported image already contains it; adding both double-paints (a flat
* color block behind/around the real art).
* - Bindings (§7.1): resolved sites emit var(--slug, literal) so a brand
* refresh propagates; unresolved sites bake the literal and carry a
* data-figma-unresolved flag. Never a dangling var().
* - visible:false nodes and fills are skipped — figma's own renderer
* semantics, not ours.
*/
import type { FigmaNodeDocument } from "./client";
import { figmaColorToCss } from "./color";
import { childDocuments } from "./nodeDocument";
import type { ResolveBindingsResult } from "./resolveBindings";
export interface RasterizeRequest {
nodeId: string;
name: string;
slug: string;
}
export interface NodeToHtmlResult {
html: string;
rasterize: RasterizeRequest[];
}
const RASTERIZE_TYPES = new Set([
"VECTOR",
"BOOLEAN_OPERATION",
"STAR",
"LINE",
"POLYGON",
"REGULAR_POLYGON",
]);
interface Box {
x: number;
y: number;
width: number;
height: number;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function boxOf(node: FigmaNodeDocument): Box | null {
const b = node.absoluteBoundingBox;
if (
isRecord(b) &&
typeof b.x === "number" &&
typeof b.y === "number" &&
typeof b.width === "number" &&
typeof b.height === "number"
)
return { x: b.x, y: b.y, width: b.width, height: b.height };
return null;
}
export { slugify } from "../tokenSlug";
import { cssVariableName as cssVarName, slugify } from "../tokenSlug";
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function round(n: number): number {
return Math.round(n * 100) / 100;
}
function firstVisibleFill(node: FigmaNodeDocument): Record<string, unknown> | null {
const fills = node.fills;
if (!Array.isArray(fills)) return null;
for (const fill of fills) {
if (isRecord(fill) && fill.visible !== false) return fill;
}
return null;
}
function gradientCss(fill: Record<string, unknown>): string | null {
const stops = fill.gradientStops;
if (!Array.isArray(stops)) return null;
const parts: string[] = [];
for (const stop of stops) {
if (!isRecord(stop) || typeof stop.position !== "number") return null;
const color = figmaColorToCss(stop.color);
if (color === null) return null;
parts.push(`${color} ${round(stop.position * 100)}%`);
}
if (parts.length === 0) return null;
// ponytail: fixed 180deg; exact angle from gradientHandlePositions when the
// probe confirms the handle-space math (spec §7.1 probe list).
return `linear-gradient(180deg, ${parts.join(", ")})`;
}
function fillCss(node: FigmaNodeDocument): string | null {
const fill = firstVisibleFill(node);
if (fill === null) return null;
if (fill.type === "SOLID") return figmaColorToCss(fill.color);
if (fill.type === "GRADIENT_LINEAR") return gradientCss(fill);
return null;
}
/** IMAGE fills (photos, icons pasted as bitmaps) have no CSS equivalent —
* route to rasterize like vectors, regardless of node.type (a plain
* RECTANGLE/FRAME carries the fill just as often as a dedicated image node). */
function hasImageFill(node: FigmaNodeDocument): boolean {
return firstVisibleFill(node)?.type === "IMAGE";
}
function dropShadowCss(effect: Record<string, unknown>): string | null {
if (!isRecord(effect.offset)) return null;
const color = figmaColorToCss(effect.color);
const { x, y } = effect.offset;
if (color === null || typeof x !== "number" || typeof y !== "number") return null;
const radius = typeof effect.radius === "number" ? effect.radius : 0;
return `box-shadow: ${round(x)}px ${round(y)}px ${round(radius)}px ${color}`;
}
function effectCss(effect: unknown): string | null {
if (!isRecord(effect) || effect.visible === false) return null;
if (effect.type === "DROP_SHADOW") return dropShadowCss(effect);
if (effect.type === "LAYER_BLUR" && typeof effect.radius === "number")
return `filter: blur(${round(effect.radius)}px)`;
return null;
}
function effectsCss(node: FigmaNodeDocument, styles: string[]): void {
const effects = node.effects;
if (!Array.isArray(effects)) return;
for (const effect of effects) {
const css = effectCss(effect);
if (css !== null) styles.push(css);
}
}
function textCss(node: FigmaNodeDocument, styles: string[]): void {
const s = node.style;
if (!isRecord(s)) return;
// fontFamily is the one content-controlled string that reaches CSS — strip
// quote/escape chars so it can't break out of the font-family value (the
// whole style attribute is additionally HTML-escaped at emission).
if (typeof s.fontFamily === "string")
styles.push(`font-family: '${s.fontFamily.replace(/['"\\;]/g, "")}'`);
if (typeof s.fontWeight === "number") styles.push(`font-weight: ${s.fontWeight}`);
if (typeof s.fontSize === "number") styles.push(`font-size: ${round(s.fontSize)}px`);
if (typeof s.lineHeightPx === "number") styles.push(`line-height: ${round(s.lineHeightPx)}px`);
if (typeof s.letterSpacing === "number" && s.letterSpacing !== 0)
styles.push(`letter-spacing: ${round(s.letterSpacing)}px`);
if (isVerticallyTrimmed(node, s.lineHeightPx)) {
styles.push("text-box-trim: trim-both", "text-box-edge: cap alphabetic");
}
}
/**
* Vertical trim: a figma text box SHORTER than its line-height is
* cap-height-trimmed bounds. Browsers place glyphs with half-leading and
* overflow the short box downward (~6px low on a 70px font, measured
* against figma's own render). text-box-trim reproduces figma's trim in
* the render engine (Chrome). Single-line text only.
*/
function isVerticallyTrimmed(node: FigmaNodeDocument, lineHeightPx: unknown): boolean {
if (typeof lineHeightPx !== "number") return false;
const box = boxOf(node);
if (box === null || box.height >= lineHeightPx - 1) return false;
return typeof node.characters === "string" && !node.characters.includes("\n");
}
interface RenderContext {
origin: Box;
bindings: ResolveBindingsResult;
rasterize: RasterizeRequest[];
usedSlugs: Set<string>;
}
function uniqueSlug(ctx: RenderContext, name: string): string {
const raw = slugify(name);
// A digit-leading id ("3D Object" → "3d-object") is valid HTML but not a
// valid CSS selector — querySelector("#3d-object") throws, which breaks
// GSAP targeting and figma-motion translation. Prefix so ids stay
// selector-safe.
const base = /^[0-9]/.test(raw) ? `n${raw}` : raw;
let slug = base;
let n = 2;
while (ctx.usedSlugs.has(slug)) slug = `${base}-${n++}`;
ctx.usedSlugs.add(slug);
return slug;
}
function backgroundValue(node: FigmaNodeDocument, ctx: RenderContext): string | null {
const literal = fillCss(node);
if (literal === null) return null;
const resolved = ctx.bindings.resolved.find(
(r) => r.nodeId === node.id && r.property === "fills",
);
if (resolved) return `var(${cssVarName(resolved.compositionVariableId)}, ${literal})`;
return literal;
}
function unresolvedAttr(node: FigmaNodeDocument, ctx: RenderContext): string {
const props = ctx.bindings.unresolved.filter((u) => u.nodeId === node.id).map((u) => u.property);
if (props.length === 0) return "";
return ` data-figma-unresolved="${escapeHtml(props.join(" "))}"`;
}
function geometryCss(node: FigmaNodeDocument, parentBox: Box, isRoot: boolean): string[] {
const box = boxOf(node);
const styles: string[] = [];
if (!box) return styles;
if (isRoot) {
styles.push(
"position: relative",
`width: ${round(box.width)}px`,
`height: ${round(box.height)}px`,
);
} else {
// CSS absolute positioning is relative to the nearest positioned
// ancestor — the PARENT's box, not the root origin. Subtracting the root
// for every depth double-offsets nested children (each level re-adds its
// ancestors' offsets), drifting content down-right and off-frame.
styles.push(
"position: absolute",
`left: ${round(box.x - parentBox.x)}px`,
`top: ${round(box.y - parentBox.y)}px`,
`width: ${round(box.width)}px`,
`height: ${round(box.height)}px`,
);
}
return styles;
}
/** Corner-radius + clip describe the node's OWN shape — meaningless once
* that shape has already been baked into a rasterized image (see
* decorationCss). Opacity stays separate: it's compositing, still correct
* to apply on top of a raster/vector export. */
function cornerAndClipCss(node: FigmaNodeDocument, styles: string[]): void {
if (node.type === "ELLIPSE") {
styles.push("border-radius: 50%");
} else if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
styles.push(`border-radius: ${round(node.cornerRadius)}px`);
}
if (node.clipsContent === true) styles.push("overflow: hidden");
}
function opacityCss(node: FigmaNodeDocument, styles: string[]): void {
if (typeof node.opacity === "number" && node.opacity < 1)
styles.push(`opacity: ${round(node.opacity)}`);
}
function decorationCss(node: FigmaNodeDocument, ctx: RenderContext, rasterized: boolean): string[] {
const styles: string[] = [];
// A rasterized node's fill/shape is already baked into the exported image
// — background-color/border-radius on top of it would double-paint (a
// flat color block behind or around the real art). Opacity and effects
// (shadow/blur) aren't baked by the export, so those still apply.
if (!rasterized) {
// backgroundValue is the binding-aware path (var(--slug, literal)) — TEXT
// color goes through it too, so a token-bound text fill keeps its link.
const bg = backgroundValue(node, ctx);
if (node.type === "TEXT") {
if (bg !== null) styles.push(`color: ${bg}`);
textCss(node, styles);
} else if (bg !== null) {
// background-color (longhand) for solid fills, never the shorthand: GSAP
// backgroundColor tweens can't read a var() through the shorthand (its
// pending-substitution longhands serialize empty), so .from/.to on an
// imported node would settle on transparent instead of the token color.
styles.push(bg.includes("gradient(") ? `background: ${bg}` : `background-color: ${bg}`);
}
cornerAndClipCss(node, styles);
}
opacityCss(node, styles);
effectsCss(node, styles);
return styles;
}
// ponytail: depth cap so a runaway auto-generated tree degrades to a skip
// instead of a RangeError; real figma frames are nowhere near this deep.
const MAX_DEPTH = 500;
function renderChildren(
node: FigmaNodeDocument,
ctx: RenderContext,
depth: number,
parentBox: Box,
): string {
const childHtml: string[] = [];
for (const child of childDocuments(node)) {
const rendered = renderNodeHtml(child, ctx, false, depth + 1, parentBox);
if (rendered.length > 0) childHtml.push(rendered);
}
return childHtml.length > 0 ? `\n${childHtml.join("\n")}\n` : "";
}
function renderNodeHtml(
node: FigmaNodeDocument,
ctx: RenderContext,
isRoot: boolean,
depth = 0,
parentBox: Box = ctx.origin,
): string {
if (node.visible === false || depth > MAX_DEPTH) return "";
const slug = uniqueSlug(ctx, node.name);
const rasterized = RASTERIZE_TYPES.has(node.type) || hasImageFill(node);
const style = escapeHtml(
[...geometryCss(node, parentBox, isRoot), ...decorationCss(node, ctx, rasterized)].join("; "),
);
// data-hf-snippet marks the file as a mountable fragment, not a standalone
// composition — the project linter skips composition-root rules for it.
const snippetAttr = isRoot ? ' data-hf-snippet=""' : "";
const idAttrs = `id="${slug}"${snippetAttr} data-figma-id="${escapeHtml(node.id)}"${unresolvedAttr(node, ctx)}`;
if (rasterized) {
ctx.rasterize.push({ nodeId: node.id, name: node.name, slug });
return `<img ${idAttrs} data-figma-rasterize="${escapeHtml(node.id)}" alt="${escapeHtml(node.name)}" style="${style}" />`;
}
if (node.type === "TEXT") {
const text = typeof node.characters === "string" ? escapeHtml(node.characters) : "";
return `<div ${idAttrs} style="${style}">${text}</div>`;
}
return `<div ${idAttrs} style="${style}">${renderChildren(node, ctx, depth, boxOf(node) ?? parentBox)}</div>`;
}
export interface NodeToHtmlOptions {
/** override for the ROOT element's slug/id — variant frames are often all
* named "Platform=Desktop", so the caller's --name must reach the DOM id,
* not just the output directory */
rootName?: string;
}
export function nodeToHtml(
root: FigmaNodeDocument,
bindings: ResolveBindingsResult,
opts: NodeToHtmlOptions = {},
): NodeToHtmlResult {
const origin = boxOf(root) ?? { x: 0, y: 0, width: 0, height: 0 };
const ctx: RenderContext = { origin, bindings, rasterize: [], usedSlugs: new Set() };
const rootForRender = opts.rootName !== undefined ? { ...root, name: opts.rootName } : root;
const html = renderNodeHtml(rootForRender, ctx, true);
return { html, rasterize: ctx.rasterize };
}
@@ -0,0 +1,37 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { parseFigmaRef } from "./parseFigmaRef";
describe("parseFigmaRef", () => {
it("extracts fileKey + nodeId from a /design/ URL and converts node-id dashes to colons", () => {
const ref = parseFigmaRef(
"https://www.figma.com/design/JjiZQGiUKqbkPCs3sviEUF/Playground?node-id=92-573&t=x",
);
expect(ref.fileKey).toBe("JjiZQGiUKqbkPCs3sviEUF");
expect(ref.nodeId).toBe("92:573");
});
it("handles /file/ URLs without a node-id", () => {
const ref = parseFigmaRef("https://figma.com/file/ABC123/Name");
expect(ref.fileKey).toBe("ABC123");
expect(ref.nodeId).toBeUndefined();
});
it("accepts fileKey:nodeId shorthand", () => {
expect(parseFigmaRef("ABC123:1-2")).toEqual({ fileKey: "ABC123", nodeId: "1:2" });
});
it("accepts a bare fileKey", () => {
expect(parseFigmaRef("ABC123")).toEqual({ fileKey: "ABC123" });
});
it("throws on empty input", () => {
expect(() => parseFigmaRef(" ")).toThrow();
});
});
describe("normalizeNodeId multi-segment", () => {
it("converts every dash in a multi-segment node id", () => {
expect(parseFigmaRef("KEY:1-2-3").nodeId).toBe("1:2:3");
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { FigmaRef } from "./types";
const FILE_KEY_RE = /\/(?:design|file|proto)\/([A-Za-z0-9]+)/;
function normalizeNodeId(raw: string): string {
return raw.replaceAll("-", ":");
}
export function parseFigmaRef(input: string): FigmaRef {
const trimmed = input.trim();
if (trimmed.length === 0) throw new Error("parseFigmaRef: empty input");
if (!trimmed.includes("/")) {
const colon = trimmed.indexOf(":");
if (colon === -1) return { fileKey: trimmed };
const fileKey = trimmed.slice(0, colon);
const node = trimmed.slice(colon + 1);
if (fileKey.length === 0) throw new Error(`parseFigmaRef: invalid ref "${input}"`);
return node.length > 0 ? { fileKey, nodeId: normalizeNodeId(node) } : { fileKey };
}
const keyMatch = trimmed.match(FILE_KEY_RE);
const fileKey = keyMatch?.[1];
if (fileKey === undefined) throw new Error(`parseFigmaRef: no fileKey in "${input}"`);
const q = trimmed.indexOf("?");
if (q !== -1) {
const raw = new URLSearchParams(trimmed.slice(q + 1)).get("node-id");
if (raw !== null && raw.length > 0) return { fileKey, nodeId: normalizeNodeId(raw) };
}
return { fileKey };
}
@@ -0,0 +1,88 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { resolveBindings } from "./resolveBindings";
import type { FigmaBindingRecord } from "./bindings";
import type { FigmaNodeDocument } from "./client";
const INDEX: FigmaBindingRecord[] = [
{
kind: "binding",
figmaId: "VariableID:1:1",
key: "kblue",
sourceFileKey: "FILE",
compositionVariableId: "figma:Blue/500",
version: "7",
},
{
kind: "binding",
figmaId: "VariableID:1:2",
key: "kbtn",
sourceFileKey: "FILE",
aliasChain: ["VariableID:1:2", "VariableID:1:1"],
compositionVariableId: "figma:button/bg",
version: "7",
},
];
function node(overrides: Partial<FigmaNodeDocument>): FigmaNodeDocument {
return { id: "9:9", name: "n", type: "RECTANGLE", ...overrides };
}
describe("resolveBindings", () => {
it("resolves a fill bound to an indexed variable", () => {
const doc = node({
boundVariables: { fills: [{ type: "VARIABLE_ALIAS", id: "VariableID:1:1" }] },
});
const out = resolveBindings(doc, INDEX);
expect(out.resolved).toEqual([
{
nodeId: "9:9",
property: "fills",
figmaId: "VariableID:1:1",
compositionVariableId: "figma:Blue/500",
},
]);
expect(out.unresolved).toEqual([]);
});
it("resolves via alias chain membership (semantic id indexed under chain)", () => {
const doc = node({
boundVariables: { fills: [{ type: "VARIABLE_ALIAS", id: "VariableID:1:2" }] },
});
const out = resolveBindings(doc, INDEX);
expect(out.resolved[0]?.compositionVariableId).toBe("figma:button/bg");
});
it("partitions unknown ids as unresolved — exact-ID only, no value matching", () => {
const doc = node({
boundVariables: { fills: [{ type: "VARIABLE_ALIAS", id: "VariableID:99:1" }] },
});
const out = resolveBindings(doc, INDEX);
expect(out.resolved).toEqual([]);
expect(out.unresolved).toEqual([
{ nodeId: "9:9", property: "fills", figmaId: "VariableID:99:1" },
]);
});
it("walks children and collects style-id sites too", () => {
const doc = node({
type: "FRAME",
children: [
node({
id: "9:10",
type: "TEXT",
styles: { text: "S:styleKey1" },
}),
],
});
const out = resolveBindings(doc, INDEX);
expect(out.unresolved).toEqual([
{ nodeId: "9:10", property: "style:text", figmaId: "S:styleKey1" },
]);
});
it("returns empty partitions for an unbound tree", () => {
const out = resolveBindings(node({}), INDEX);
expect(out).toEqual({ resolved: [], unresolved: [] });
});
});
@@ -0,0 +1,97 @@
/**
* Binding resolution pass (design spec §7.1): scan a node tree's complete
* binding set — boundVariables and style ids, alias chains honored — and
* partition against the binding index BEFORE any CSS is emitted.
*
* Exact-ID matching only. A missed link bakes a visually-correct literal;
* a wrong link silently changes color at the next brand refresh. Never
* match by value or name.
*
* NOTE: the consumer-side boundVariables shape is probe-flagged in the spec
* (§7.1 pre-build probes). Extraction here is tolerant: single alias objects
* and arrays of alias objects both count; unknown shapes are ignored rather
* than guessed at.
*/
import type { FigmaBindingRecord } from "./bindings";
import type { FigmaNodeDocument } from "./client";
import { childDocuments } from "./nodeDocument";
export interface BindingSite {
nodeId: string;
/** boundVariables property ("fills") or style slot prefixed "style:" ("style:text") */
property: string;
figmaId: string;
}
export interface ResolvedBindingSite extends BindingSite {
compositionVariableId: string;
}
export interface ResolveBindingsResult {
resolved: ResolvedBindingSite[];
unresolved: BindingSite[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function aliasId(value: unknown): string | null {
if (isRecord(value) && value.type === "VARIABLE_ALIAS" && typeof value.id === "string")
return value.id;
return null;
}
function boundVariableSites(node: FigmaNodeDocument, out: BindingSite[]): void {
const bound = node.boundVariables;
if (!isRecord(bound)) return;
for (const [property, value] of Object.entries(bound)) {
const candidates = Array.isArray(value) ? value : [value];
for (const item of candidates) {
const id = aliasId(item);
if (id) out.push({ nodeId: node.id, property, figmaId: id });
}
}
}
function styleSites(node: FigmaNodeDocument, out: BindingSite[]): void {
const styles = node.styles;
if (!isRecord(styles)) return;
for (const [slot, styleId] of Object.entries(styles)) {
if (typeof styleId === "string" && styleId.length > 0)
out.push({ nodeId: node.id, property: `style:${slot}`, figmaId: styleId });
}
}
function collectSites(node: FigmaNodeDocument, out: BindingSite[]): void {
boundVariableSites(node, out);
styleSites(node, out);
for (const child of childDocuments(node)) collectSites(child, out);
}
function findInIndex(index: FigmaBindingRecord[], figmaId: string): FigmaBindingRecord | null {
for (const b of index) {
if (b.figmaId === figmaId) return b;
if (b.aliasChain?.includes(figmaId)) return b;
if (b.key !== undefined && b.key === figmaId) return b;
}
return null;
}
export function resolveBindings(
node: FigmaNodeDocument,
index: FigmaBindingRecord[],
): ResolveBindingsResult {
const sites: BindingSite[] = [];
collectSites(node, sites);
const resolved: ResolvedBindingSite[] = [];
const unresolved: BindingSite[] = [];
for (const site of sites) {
const match = findInIndex(index, site.figmaId);
if (match) resolved.push({ ...site, compositionVariableId: match.compositionVariableId });
else unresolved.push(site);
}
return { resolved, unresolved };
}
@@ -0,0 +1,98 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { sanitizeSvg } from "./sanitizeSvg";
describe("sanitizeSvg", () => {
it("strips <script> blocks including content", () => {
const dirty = `<svg><script>alert(1)</script><rect width="1" height="1"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("script");
expect(clean).toContain("<rect");
});
it("strips foreignObject subtrees", () => {
const dirty = `<svg><foreignObject><iframe src="https://evil"/></foreignObject><circle r="2"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("foreignObject");
expect(clean).not.toContain("iframe");
expect(clean).toContain("<circle");
});
it("strips on* event handler attributes", () => {
const dirty = `<svg onload="evil()"><rect onclick="evil()" width="1"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onload");
expect(clean).not.toContain("onclick");
expect(clean).toContain('width="1"');
});
it("strips javascript: and external http(s) hrefs but keeps local fragments", () => {
const dirty = [
`<svg>`,
`<a href="javascript:evil()"><text>x</text></a>`,
`<use xlink:href="https://evil.example/sprite.svg#icon"/>`,
`<use href="#localClip"/>`,
`</svg>`,
].join("");
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("javascript:");
expect(clean).not.toContain("evil.example");
expect(clean).toContain('href="#localClip"');
});
it("keeps data:image hrefs (figma embeds rasters this way)", () => {
const dirty = `<svg><image href="data:image/png;base64,AAAA"/></svg>`;
expect(sanitizeSvg(dirty)).toContain("data:image/png;base64,AAAA");
});
it("leaves a typical clean figma export untouched apart from whitespace", () => {
const clean = `<svg width="10" height="10" viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h10v10H0z" fill="#123456" clip-path="url(#clip0)"/><defs><clipPath id="clip0"><rect width="10" height="10"/></clipPath></defs></svg>`;
expect(sanitizeSvg(clean)).toBe(clean);
});
});
describe("sanitizeSvg hardening", () => {
it("strips nested script/foreignObject without leaving inner content", () => {
const dirty = `<svg><foreignObject><foreignObject><iframe/></foreignObject></foreignObject></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("foreignObject");
expect(clean).not.toContain("iframe");
});
it("strips <style> blocks (css @import exfil)", () => {
const clean = sanitizeSvg(`<svg><style>@import url("https://x/y.css");</style><rect/></svg>`);
expect(clean).not.toContain("style");
expect(clean).toContain("<rect");
});
it("strips unquoted on* handlers", () => {
expect(sanitizeSvg(`<svg><rect onclick=evil() /></svg>`)).not.toContain("onclick");
});
it("drops non-image data: hrefs but keeps data:image and #fragments", () => {
const dirty = `<svg><a href="data:text/html,<script>1</script>">x</a><use href="#clip"/><image href="data:image/png;base64,AA"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("data:text/html");
expect(clean).toContain('href="#clip"');
expect(clean).toContain("data:image/png");
});
it("drops blob: and protocol-relative hrefs", () => {
const clean = sanitizeSvg(`<svg><a href="blob:x">a</a><a href='//evil/x'>b</a></svg>`);
expect(clean).not.toContain("blob:");
expect(clean).not.toContain("//evil");
});
});
describe("sanitizeSvg close-tag variants (js/bad-tag-filter)", () => {
it("strips scripts whose close tag carries whitespace or junk before >", () => {
const dirty = "<svg><script>evil()</script\t\n bar><rect/></svg>";
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("script");
expect(clean).not.toContain("evil");
expect(clean).toContain("<rect");
});
it("strips style/foreignObject with attribute-bearing close tags", () => {
const dirty = `<svg><style>@import url(x)</style x><foreignObject><iframe/></foreignObject foo="1"></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("style");
expect(clean).not.toContain("foreignObject");
expect(clean).not.toContain("iframe");
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* Sanitize a figma-exported SVG before it touches disk (design spec §5).
*
* Threat model: figma-SHAPED exports, hardened against the cheap adversarial
* variants (nesting, unquoted attrs, scheme smuggling). This is a lexical
* pass, not a general-purpose HTML sanitizer — content from arbitrary
* untrusted sources should go through a real sanitizer (DOMPurify) instead.
* Strips:
* - <script> elements (with content) and <style> blocks (@import exfil)
* - <foreignObject> subtrees (arbitrary embedded HTML)
* - on* event-handler attributes (quoted and unquoted)
* - href/xlink:href values unless local fragment (#id) or data:image/
*
* Keeps local fragment refs (#id) and data:image embeds, which figma uses
* for clip paths and embedded rasters.
*/
/** Apply a replacement until the output stops changing (defeats nesting). */
function replaceStable(input: string, pattern: RegExp, replacement: string): string {
let out = input;
let prev;
do {
prev = out;
out = out.replace(pattern, replacement);
} while (out !== prev);
return out;
}
export function sanitizeSvg(svg: string): string {
let out = svg;
out = replaceStable(out, /<script\b[\s\S]*?<\/script\b[^>]*>/gi, "");
out = replaceStable(out, /<script\b[^>]*\/>/gi, "");
out = replaceStable(out, /<style\b[\s\S]*?<\/style\b[^>]*>/gi, "");
out = replaceStable(out, /<foreignObject\b[\s\S]*?<\/foreignObject\b[^>]*>/gi, "");
out = replaceStable(out, /<foreignObject\b[^>]*\/>/gi, "");
// Nesting leaves inert orphan close tags after the stable pass — drop them.
out = out.replace(/<\/(?:script|style|foreignObject)\b[^>]*>/gi, "");
// on* handler attributes: quoted and unquoted forms.
out = replaceStable(out, /\son[a-z]+\s*=\s*"[^"]*"/gi, "");
out = replaceStable(out, /\son[a-z]+\s*=\s*'[^']*'/gi, "");
out = replaceStable(out, /\son[a-z]+\s*=\s*[^\s>'"]+/gi, "");
// href-like attributes: POSITIVE allowlist — keep only local fragments and
// data:image embeds; drop everything else (javascript:, https?:, blob:,
// vbscript:, data:text/html, protocol-relative, …).
// xmlns declarations are attribute *names*, not href values — untouched.
out = out.replace(/\s(href|xlink:href)\s*=\s*"([^"]*)"/gi, (m, attr: string, value: string) =>
isAllowedHref(value) ? m : "",
);
out = out.replace(/\s(href|xlink:href)\s*=\s*'([^']*)'/gi, (m, attr: string, value: string) =>
isAllowedHref(value) ? m : "",
);
return out;
}
function isAllowedHref(value: string): boolean {
const v = value.trim().toLowerCase();
return v.startsWith("#") || v.startsWith("data:image/");
}
@@ -0,0 +1,175 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { tokensToVariables } from "./tokensToVariables";
import type { FigmaVariablesResult } from "./client";
const VARS: FigmaVariablesResult = {
variables: {
"VariableID:1:1": {
name: "Blue/500",
key: "kblue",
resolvedType: "COLOR",
valuesByMode: { m1: { r: 0, g: 0.4, b: 1, a: 1 } },
variableCollectionId: "c1",
},
"VariableID:1:2": {
name: "button/bg",
key: "kbtn",
resolvedType: "COLOR",
valuesByMode: { m1: { type: "VARIABLE_ALIAS", id: "VariableID:1:1" } },
variableCollectionId: "c1",
},
"VariableID:1:3": {
name: "radius/md",
key: "krad",
resolvedType: "FLOAT",
valuesByMode: { m1: 8 },
variableCollectionId: "c1",
},
},
variableCollections: {
c1: { defaultModeId: "m1", modes: [{ modeId: "m1", name: "Light" }] },
},
};
const SOURCE = { fileKey: "FILE", version: "7" };
describe("tokensToVariables", () => {
it("maps color variables to composition color entries with hex defaults", () => {
const out = tokensToVariables(VARS, SOURCE);
const blue = out.entries.find((e) => e.id === "figma:Blue/500");
expect(blue).toMatchObject({ type: "color", label: "Blue/500", default: "#0066FF" });
});
it("resolves alias chains to the leaf value and records the chain on the binding", () => {
const out = tokensToVariables(VARS, SOURCE);
const btn = out.entries.find((e) => e.id === "figma:button/bg");
expect(btn?.default).toBe("#0066FF");
const binding = out.bindings.find((b) => b.figmaId === "VariableID:1:2");
expect(binding?.aliasChain).toEqual(["VariableID:1:2", "VariableID:1:1"]);
expect(binding?.compositionVariableId).toBe("figma:button/bg");
});
it("maps FLOAT to number entries and stamps provenance on every binding", () => {
const out = tokensToVariables(VARS, SOURCE);
const rad = out.entries.find((e) => e.id === "figma:radius/md");
expect(rad).toMatchObject({ type: "number", default: 8 });
for (const b of out.bindings) {
expect(b.sourceFileKey).toBe("FILE");
expect(b.version).toBe("7");
expect(b.kind).toBe("binding");
}
});
it("emits an alpha color as rgba()", () => {
const out = tokensToVariables(
{
variables: {
"VariableID:2:1": {
name: "overlay",
resolvedType: "COLOR",
valuesByMode: { m1: { r: 0, g: 0, b: 0, a: 0.5 } },
},
},
variableCollections: {},
},
SOURCE,
);
expect(out.entries[0]?.default).toBe("rgba(0, 0, 0, 0.5)");
});
it("survives alias cycles without hanging and skips the unresolvable variable", () => {
const out = tokensToVariables(
{
variables: {
"VariableID:3:1": {
name: "a",
resolvedType: "COLOR",
valuesByMode: { m1: { type: "VARIABLE_ALIAS", id: "VariableID:3:2" } },
},
"VariableID:3:2": {
name: "b",
resolvedType: "COLOR",
valuesByMode: { m1: { type: "VARIABLE_ALIAS", id: "VariableID:3:1" } },
},
},
variableCollections: {},
},
SOURCE,
);
expect(out.entries).toEqual([]);
});
it("writes a sidecar with every token including modes", () => {
const out = tokensToVariables(VARS, SOURCE);
expect(out.sidecar.source).toEqual(SOURCE);
const blue = out.sidecar.tokens.find((t) => t.name === "Blue/500");
expect(blue).toMatchObject({ figmaId: "VariableID:1:1", key: "kblue", type: "color" });
});
});
describe("tokensToVariables collection semantics", () => {
const TWO_COLLECTIONS: FigmaVariablesResult = {
variables: {
"VariableID:9:1": {
name: "Blue/500",
resolvedType: "COLOR",
valuesByMode: { m1: { r: 0, g: 0, b: 1, a: 1 } },
variableCollectionId: "sem",
},
"VariableID:9:2": {
name: "Blue/500",
resolvedType: "COLOR",
valuesByMode: { m1: { r: 0, g: 0.5, b: 1, a: 1 } },
variableCollectionId: "prim",
},
},
variableCollections: {
sem: { name: "Semantic", defaultModeId: "m1" },
prim: { name: "Primitive", defaultModeId: "m1" },
},
};
it("namespaces composition ids by collection so same-name variables never collide", () => {
const out = tokensToVariables(TWO_COLLECTIONS, { fileKey: "F", version: "1" });
const ids = out.entries.map((e) => e.id);
expect(new Set(ids).size).toBe(2);
expect(ids).toContain("figma:Semantic/Blue/500");
expect(ids).toContain("figma:Primitive/Blue/500");
});
it("prefers the collection defaultModeId over insertion order", () => {
const multiMode: FigmaVariablesResult = {
variables: {
"VariableID:9:3": {
name: "Ink",
resolvedType: "COLOR",
valuesByMode: {
dark: { r: 1, g: 1, b: 1, a: 1 },
light: { r: 0, g: 0, b: 0, a: 1 },
},
variableCollectionId: "c",
},
},
variableCollections: { c: { name: "Modes", defaultModeId: "light" } },
};
const out = tokensToVariables(multiMode, { fileKey: "F", version: "1" });
expect(out.entries[0]?.default).toBe("#000000");
});
it("rejects type-mismatched values (stringified FLOAT) instead of passing them through", () => {
const bad: FigmaVariablesResult = {
variables: {
"VariableID:9:4": {
name: "Gap",
resolvedType: "FLOAT",
valuesByMode: { m1: "8" },
variableCollectionId: "c",
},
},
variableCollections: { c: { name: "N", defaultModeId: "m1" } },
};
const out = tokensToVariables(bad, { fileKey: "F", version: "1" });
expect(out.entries).toHaveLength(0);
});
});
@@ -0,0 +1,172 @@
/**
* Phase 2 translator: figma variables → composition brand-variable entries,
* a human-readable sidecar, and binding-index records (design spec §6, §7.1).
*
* Pure — REST payload in, artifacts out. Alias chains are walked to the leaf
* value (cycle-safe) but the binding records the semantic id the designer
* bound, so a swapped primitive doesn't orphan the link.
*/
import type { FigmaVariablePayload, FigmaVariablesResult } from "./client";
import type { FigmaBindingRecord } from "./bindings";
import { figmaColorToCss } from "./color";
/** data-composition-variables entry (runtime getVariables contract). */
export interface CompositionVariableEntry {
id: string;
type: "string" | "number" | "color" | "boolean";
label: string;
default: string | number | boolean;
brandRole?: string;
}
export interface FigmaTokenSidecarEntry {
name: string;
type: string;
figmaId: string;
key?: string;
value: string | number | boolean | null;
}
export interface FigmaTokensSidecar {
source: TokenSource;
tokens: FigmaTokenSidecarEntry[];
}
export interface TokenSource {
fileKey: string;
version: string;
}
export interface TokensToVariablesResult {
entries: CompositionVariableEntry[];
bindings: FigmaBindingRecord[];
sidecar: FigmaTokensSidecar;
}
const TYPE_MAP: Record<string, CompositionVariableEntry["type"]> = {
COLOR: "color",
FLOAT: "number",
STRING: "string",
BOOLEAN: "boolean",
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/**
* The collection's defaultModeId is the authority for "which mode is the
* base value" (Light vs Dark). Falls back to first-inserted mode when the
* collection or its default mode isn't in the payload.
*/
function baseModeValue(
payload: FigmaVariablePayload,
collections: Record<string, unknown>,
): unknown {
const modes = payload.valuesByMode ?? {};
const collection = collections[payload.variableCollectionId ?? ""];
if (isRecord(collection) && typeof collection.defaultModeId === "string") {
const preferred = modes[collection.defaultModeId];
if (preferred !== undefined) return preferred;
}
for (const value of Object.values(modes)) return value;
return undefined;
}
function collectionName(
payload: FigmaVariablePayload,
collections: Record<string, unknown>,
): string | null {
const collection = collections[payload.variableCollectionId ?? ""];
return isRecord(collection) && typeof collection.name === "string" ? collection.name : null;
}
/** Follow VARIABLE_ALIAS links to a leaf value; null on cycle/missing. */
function resolveValue(
start: string,
vars: FigmaVariablesResult,
): { value: unknown; chain: string[] } | null {
const chain: string[] = [];
const seen = new Set<string>();
let currentId = start;
while (!seen.has(currentId)) {
chain.push(currentId);
seen.add(currentId);
const payload = vars.variables[currentId];
if (!payload) return null;
const value = baseModeValue(payload, vars.variableCollections);
if (isRecord(value) && value.type === "VARIABLE_ALIAS" && typeof value.id === "string") {
currentId = value.id;
continue;
}
return { value, chain };
}
return null; // cycle
}
/** Value must match the declared resolvedType — a stringified "8" for a
* FLOAT would silently break the CompositionVariableEntry contract. */
function toEntryValue(
resolvedType: string | undefined,
raw: unknown,
): string | number | boolean | null {
if (resolvedType === "COLOR") return figmaColorToCss(raw);
if (resolvedType === "FLOAT") return typeof raw === "number" ? raw : null;
if (resolvedType === "BOOLEAN") return typeof raw === "boolean" ? raw : null;
if (resolvedType === "STRING") return typeof raw === "string" ? raw : null;
return null;
}
export function tokensToVariables(
vars: FigmaVariablesResult,
source: TokenSource,
): TokensToVariablesResult {
const entries: CompositionVariableEntry[] = [];
const bindings: FigmaBindingRecord[] = [];
const sidecarTokens: FigmaTokenSidecarEntry[] = [];
for (const [figmaId, payload] of Object.entries(vars.variables)) {
const entryType = TYPE_MAP[payload.resolvedType ?? ""];
const resolved = resolveValue(figmaId, vars);
const value = resolved ? toEntryValue(payload.resolvedType, resolved.value) : null;
// Namespace by collection: figma allows the same variable name in
// different collections (Semantic Blue/500 vs Primitive Blue/500), and a
// collision here would silently merge two distinct bindings.
const collection = collectionName(payload, vars.variableCollections);
const compositionVariableId = collection
? `figma:${collection}/${payload.name}`
: `figma:${payload.name}`;
// Sidecar keeps EVERY variable — including unresolvable ones (value:
// null) — for designer visibility into what didn't map; entries/bindings
// below only get the mappable subset.
sidecarTokens.push({
name: payload.name,
type: entryType ?? payload.resolvedType ?? "unknown",
figmaId,
key: payload.key,
value,
});
if (!entryType || value === null || !resolved) continue;
entries.push({
id: compositionVariableId,
type: entryType,
label: payload.name,
default: value,
});
bindings.push({
kind: "binding",
figmaId,
key: payload.key,
sourceFileKey: source.fileKey,
aliasChain: resolved.chain.length > 1 ? resolved.chain : undefined,
compositionVariableId,
version: source.version,
});
}
return { entries, bindings, sidecar: { source, tokens: sidecarTokens } };
}
+90
View File
@@ -0,0 +1,90 @@
export interface FigmaRef {
fileKey: string;
nodeId?: string;
}
export type FigmaAssetFormat = "png" | "svg" | "jpg" | "pdf";
export interface FigmaProvenance {
source: "figma";
fileKey: string;
nodeId: string;
version?: string;
format?: FigmaAssetFormat;
scale?: number;
}
export interface FigmaManifestRecord {
id: string;
type: "image" | "video";
path: string;
source: string;
description?: string;
/** media-use interop: lets `resolve --entity` find figma-imported assets */
entity?: string;
width?: number;
height?: number;
provenance: FigmaProvenance;
}
export interface AssetSnippet {
path: string;
html: string;
}
/** A motion.dev easing value as returned by get_motion_context: a named ease
* (e.g. "linear") or a cubic-bezier control-point array [x1,y1,x2,y2]. */
export type MotionEase = string | [number, number, number, number];
/** One animated property, normalized from get_motion_context's motion.dev snippet. */
export interface MotionTrack {
/** motion.dev property name: "opacity" | "x" | "y" | "scaleX" | "scaleY" | "rotation" | ... */
property: string;
values: Array<number | string>;
/** normalized 0..1, same length as values */
times: number[];
/** length values.length - 1; ease[i] governs the segment values[i] -> values[i+1] */
ease: MotionEase[];
/** seconds */
duration: number;
/** Infinity or a finite count; clamped to finite during translation */
repeat?: number;
}
export interface MotionDoc {
/** string-literal CSS selector for the target element, e.g. "#hero-title" */
selector: string;
tracks: MotionTrack[];
}
export type MappedEase =
| { kind: "named"; ease: string }
| { kind: "bezier"; bezier: [number, number, number, number] };
export interface CustomEaseRef {
name: string;
bezier: [number, number, number, number];
}
export interface GsapKeyframeStep {
value: number | string;
/** seconds */
duration: number;
/** GSAP ease string or a registered CustomEase name */
ease: string;
}
export interface GsapTween {
selector: string;
property: string;
initial: number | string;
steps: GsapKeyframeStep[];
/** finite; 0 = play once */
repeat: number;
}
export interface TimelineSpec {
timelineId: string;
tweens: GsapTween[];
customEases: CustomEaseRef[];
}
+7
View File
@@ -0,0 +1,7 @@
// Moved to @hyperframes/parsers. Re-exported here for back-compat.
export {
FONT_ALIAS_MAP,
FONT_ALIAS_KEYS,
CANONICAL_FONT_DISPLAY_NAMES,
resolveAliasDisplayName,
} from "@hyperframes/parsers";
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeAll } from "vitest";
import {
locateSystemFont,
clearSystemFontCache,
SYSTEM_FONT_SIZE_LIMIT,
} from "./systemFontLocator";
describe("systemFontLocator", { timeout: 15_000 }, () => {
beforeAll(() => {
clearSystemFontCache();
});
it("returns null for nonexistent fonts", () => {
const result = locateSystemFont("nonexistent-font-xyz-12345");
expect(result).toBeNull();
});
it("normalizes case when looking up fonts", () => {
const lower = locateSystemFont("helvetica");
const upper = locateSystemFont("HELVETICA");
if (lower === null) {
expect(upper).toBeNull();
} else {
expect(upper).not.toBeNull();
expect(upper!.path).toBe(lower.path);
}
});
it("returns a valid format field when a font is found", () => {
const result =
locateSystemFont("Helvetica") ?? locateSystemFont("Arial") ?? locateSystemFont("DejaVu Sans");
if (result) {
expect(["ttf", "otf", "woff2", "woff", "ttc"]).toContain(result.format);
expect(result.path).toBeTruthy();
}
});
it("caches results across calls", () => {
const first = locateSystemFont("nonexistent-font-xyz-12345");
const second = locateSystemFont("nonexistent-font-xyz-12345");
expect(first).toBeNull();
expect(second).toBeNull();
});
it("clearSystemFontCache resets the cache", () => {
locateSystemFont("nonexistent-font-xyz-12345");
clearSystemFontCache();
const result = locateSystemFont("nonexistent-font-xyz-12345");
expect(result).toBeNull();
});
it("exports SYSTEM_FONT_SIZE_LIMIT as 5MB", () => {
expect(SYSTEM_FONT_SIZE_LIMIT).toBe(5 * 1024 * 1024);
});
it("strips quotes from family name input", () => {
const result = locateSystemFont('"nonexistent-font-xyz-12345"');
expect(result).toBeNull();
});
it("returns null for empty string", () => {
expect(locateSystemFont("")).toBeNull();
expect(locateSystemFont(" ")).toBeNull();
});
if (process.platform === "darwin") {
it("finds Helvetica on macOS", () => {
const result = locateSystemFont("Helvetica");
expect(result).not.toBeNull();
expect(result!.path).toMatch(/\.(ttf|ttc|otf)$/i);
});
it("finds Courier on macOS", () => {
const result = locateSystemFont("Courier");
expect(result).not.toBeNull();
});
}
});
@@ -0,0 +1,417 @@
import { execFileSync } from "node:child_process";
import { existsSync, lstatSync, readdirSync, realpathSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join, resolve } from "node:path";
export const SYSTEM_FONT_SIZE_LIMIT = 5 * 1024 * 1024;
const PROFILER_TIMEOUT_MS = 5000;
const FC_MATCH_TIMEOUT_MS = 3000;
export type FontFileFormat = "ttf" | "otf" | "woff2" | "woff" | "ttc";
export interface LocatedFont {
path: string;
format: FontFileFormat;
}
export const FONT_EXT_RE = /\.(otf|ttf|ttc|woff2?)$/i;
const FORMAT_PRIORITY: Record<FontFileFormat, number> = {
woff2: 0,
otf: 1,
ttf: 2,
woff: 3,
ttc: 4,
};
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 REGULAR_TOKENS = new Set(["regular", "roman", "normal", "book"]);
const cache = new Map<string, LocatedFont | null>();
let allowedDirsCache: string[] | null = null;
function getAllowedFontDirs(): string[] {
if (allowedDirsCache) return allowedDirsCache;
allowedDirsCache = fontDirectories()
.filter((d) => existsSync(d))
.map((d) => {
try {
return realpathSync(d);
} catch {
return resolve(d);
}
});
return allowedDirsCache;
}
function isPathBounded(filePath: string): boolean {
try {
const real = realpathSync(filePath);
const allowed = getAllowedFontDirs();
return allowed.some((dir) => real.startsWith(dir + "/") || real.startsWith(dir + "\\"));
} catch {
return false;
}
}
function isRegularFile(filePath: string): boolean {
try {
const lst = lstatSync(filePath);
if (lst.isSymbolicLink())
return isPathBounded(filePath) && lstatSync(realpathSync(filePath)).isFile();
return lst.isFile();
} catch {
return false;
}
}
function normalizeName(name: string): string {
return name
.trim()
.replace(/^['"]|['"]$/g, "")
.trim()
.toLowerCase();
}
function extensionToFormat(ext: string): FontFileFormat {
const lower = ext.toLowerCase().replace(/^\./, "");
if (lower === "woff2") return "woff2";
if (lower === "woff") return "woff";
if (lower === "otf") return "otf";
if (lower === "ttc") return "ttc";
return "ttf";
}
export 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 isRegularWeight(fileName: string): boolean {
const lower = fileName.toLowerCase();
if (REGULAR_TOKENS.has(lower.replace(FONT_EXT_RE, "").split(/[-_ ]/).pop() ?? "")) return true;
return !lower.includes("bold") && !lower.includes("italic") && !lower.includes("light");
}
export 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"),
join(
process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"),
"Microsoft",
"Windows",
"Fonts",
),
];
}
return [
join(home, ".fonts"),
join(home, ".local", "share", "fonts"),
"/usr/local/share/fonts",
"/usr/share/fonts",
];
}
export interface FontFileEntry {
path: string;
fileName: string;
family: string;
}
/**
* Iterates font files in a directory (up to `depth` 2) and yields each file's
* path, filename, and derived family name. Shared by the font listing route
* and the per-family locator to avoid duplicating the directory scan loop.
*/
export function collectFontFileEntries(dir: string, depth = 0): FontFileEntry[] {
if (!existsSync(dir) || depth > 2) return [];
const entries: FontFileEntry[] = [];
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
entries.push(...collectFontFileEntries(fullPath, depth + 1));
continue;
}
if (!FONT_EXT_RE.test(entry.name)) continue;
if (!isRegularFile(fullPath)) continue;
const family = toFamilyName(entry.name);
if (family) entries.push({ path: fullPath, fileName: entry.name, family });
}
} catch {
// Directory read failed — skip
}
return entries;
}
interface FontCandidate {
path: string;
format: FontFileFormat;
isRegular: boolean;
}
function collectCandidatesFromDir(dir: string, targetFamily: string, depth = 0): FontCandidate[] {
return collectFontFileEntries(dir, depth)
.filter((e) => normalizeName(e.family) === targetFamily)
.map((e) => {
const ext = e.fileName.match(FONT_EXT_RE)?.[1] ?? "ttf";
return {
path: e.path,
format: extensionToFormat(ext),
isRegular: isRegularWeight(e.fileName),
};
});
}
function pickBestCandidate(candidates: FontCandidate[]): LocatedFont | null {
if (candidates.length === 0) return null;
candidates.sort((a, b) => {
if (a.isRegular !== b.isRegular) return a.isRegular ? -1 : 1;
return (FORMAT_PRIORITY[a.format] ?? 9) - (FORMAT_PRIORITY[b.format] ?? 9);
});
const best = candidates[0]!;
return { path: best.path, format: best.format };
}
type SystemProfilerEntry = {
family: string;
path: string;
format: FontFileFormat;
isRegular: boolean;
};
let profilerCache: Map<string, SystemProfilerEntry[]> | null = null;
// fallow-ignore-next-line complexity
function getSystemProfilerIndex(): Map<string, SystemProfilerEntry[]> {
if (profilerCache) return profilerCache;
profilerCache = new Map();
if (platform() !== "darwin") return profilerCache;
try {
const raw = execFileSync("system_profiler", ["SPFontsDataType", "-json"], {
encoding: "utf8",
maxBuffer: 12 * 1024 * 1024,
timeout: PROFILER_TIMEOUT_MS,
});
const parsed = JSON.parse(raw);
if (!parsed?.SPFontsDataType || !Array.isArray(parsed.SPFontsDataType)) return profilerCache;
for (const fontEntry of parsed.SPFontsDataType) {
if (!fontEntry?.typefaces || !Array.isArray(fontEntry.typefaces)) continue;
for (const typeface of fontEntry.typefaces) {
if (!typeface) continue;
const family = typeface.family ?? typeface.fullname ?? typeface._name;
if (typeof family !== "string") continue;
const filePath = typeface.path;
if (typeof filePath !== "string" || !FONT_EXT_RE.test(filePath)) continue;
const normalized = normalizeName(family);
const ext = filePath.match(FONT_EXT_RE)?.[1] ?? "ttf";
const entry: SystemProfilerEntry = {
family: normalized,
path: filePath,
format: extensionToFormat(ext),
isRegular: isRegularWeight(filePath),
};
const list = profilerCache.get(normalized) ?? [];
list.push(entry);
profilerCache.set(normalized, list);
}
}
} catch {
// system_profiler unavailable
}
return profilerCache;
}
function locateViaSystemProfiler(targetFamily: string): LocatedFont | null {
const index = getSystemProfilerIndex();
const entries = index.get(targetFamily);
if (!entries || entries.length === 0) return null;
const candidates: FontCandidate[] = entries
.filter((e) => isRegularFile(e.path) && isPathBounded(e.path))
.map((e) => ({ path: e.path, format: e.format, isRegular: e.isRegular }));
return pickBestCandidate(candidates);
}
// fallow-ignore-next-line complexity
function locateViaFcMatch(targetFamily: string): LocatedFont | null {
if (platform() !== "linux") return null;
try {
const result = execFileSync("fc-match", [targetFamily, "--format=%{file}"], {
encoding: "utf8",
timeout: FC_MATCH_TIMEOUT_MS,
}).trim();
if (!result || !isRegularFile(result) || !isPathBounded(result)) return null;
const fileName = result.split("/").pop() ?? "";
const derivedFamily = toFamilyName(fileName);
if (!derivedFamily || normalizeName(derivedFamily) !== targetFamily) return null;
const ext = fileName.match(FONT_EXT_RE)?.[1] ?? "ttf";
return { path: result, format: extensionToFormat(ext) };
} catch {
return null;
}
}
export function locateSystemFont(family: string): LocatedFont | null {
const normalized = normalizeName(family);
if (!normalized) return null;
const cached = cache.get(normalized);
if (cached !== undefined) return cached;
let result: LocatedFont | null = null;
result = locateViaSystemProfiler(normalized);
if (!result) {
result = locateViaFcMatch(normalized);
}
if (!result) {
const allCandidates: FontCandidate[] = [];
for (const dir of fontDirectories()) {
allCandidates.push(...collectCandidatesFromDir(dir, normalized));
}
result = pickBestCandidate(allCandidates);
}
cache.set(normalized, result);
return result;
}
export interface LocatedFontVariant extends LocatedFont {
weight: string;
style: "normal" | "italic";
}
const WEIGHT_TOKENS: Record<string, string> = {
thin: "100",
hairline: "100",
ultralight: "200",
extralight: "200",
light: "300",
regular: "400",
normal: "400",
book: "400",
roman: "400",
medium: "500",
demibold: "600",
semibold: "600",
bold: "700",
extrabold: "800",
ultrabold: "800",
heavy: "800",
black: "900",
ultrablack: "950",
};
const WEIGHT_TOKENS_SORTED = Object.entries(WEIGHT_TOKENS).sort(([a], [b]) => b.length - a.length);
function inferWeightAndStyle(fileName: string): { weight: string; style: "normal" | "italic" } {
const lower = fileName.toLowerCase().replace(FONT_EXT_RE, "");
const style = lower.includes("italic") || lower.includes("oblique") ? "italic" : "normal";
for (const [token, weight] of WEIGHT_TOKENS_SORTED) {
if (lower.includes(token)) return { weight, style };
}
return { weight: "400", style };
}
export function locateSystemFontVariants(family: string): LocatedFontVariant[] {
const normalized = normalizeName(family);
if (!normalized) return [];
const variants: LocatedFontVariant[] = [];
const profilerIndex = getSystemProfilerIndex();
const profilerEntries = profilerIndex.get(normalized);
if (profilerEntries && profilerEntries.length > 0) {
for (const e of profilerEntries) {
if (!isRegularFile(e.path) || !isPathBounded(e.path)) continue;
const { weight, style } = inferWeightAndStyle(e.path);
variants.push({ path: e.path, format: e.format, weight, style });
}
if (variants.length > 0) return dedupeVariants(variants);
}
const allCandidates: FontCandidate[] = [];
for (const dir of fontDirectories()) {
allCandidates.push(...collectCandidatesFromDir(dir, normalized));
}
for (const c of allCandidates) {
const { weight, style } = inferWeightAndStyle(c.path);
variants.push({ path: c.path, format: c.format, weight, style });
}
return dedupeVariants(variants);
}
function dedupeVariants(variants: LocatedFontVariant[]): LocatedFontVariant[] {
const seen = new Map<string, LocatedFontVariant>();
for (const v of variants) {
const key = `${v.weight}:${v.style}`;
if (!seen.has(key)) seen.set(key, v);
}
return Array.from(seen.values());
}
export function getSystemProfilerFamilies(): string[] {
const index = getSystemProfilerIndex();
return Array.from(index.keys());
}
export function clearSystemFontCache(): void {
cache.clear();
profilerCache = null;
allowedDirsCache = null;
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { getPositionEditsRenderScript } from "./position-edits-render-inline";
describe("getPositionEditsRenderScript", () => {
it("returns a non-empty IIFE string built from the real algorithm", () => {
const script = getPositionEditsRenderScript();
expect(script.length).toBeGreaterThan(0);
expect(script).toContain("data-hf-edit-base-x");
});
it("is a safe no-op without position-edit markers", () => {
document.body.innerHTML = "<h1>no edits</h1>";
expect(() => new Function(getPositionEditsRenderScript())()).not.toThrow();
expect(document.querySelector("h1")?.style.getPropertyValue("translate")).toBe("");
});
it("applies the translate delta when markers are present", () => {
document.body.innerHTML =
'<h1 data-x="10" data-y="0" data-hf-edit-base-x="0" data-hf-edit-base-y="0">hi</h1>';
new Function(getPositionEditsRenderScript())();
expect(document.querySelector("h1")?.style.getPropertyValue("translate")).toBe("10px 0px");
});
});
@@ -0,0 +1,8 @@
// AUTO-GENERATED by scripts/build-position-edits-render.ts - do not edit
const POSITION_EDITS_RENDER_IIFE: string =
'"use strict";(()=>{function v(){return globalThis}function A(e,t){if(typeof window>"u")return;let o=v(),r=o.__hf?.onSwallowed;if(r)try{r({label:e,error:t})}catch(i){}(o.__hfDebug||o.__HYPERFRAMES_DEBUG)&&console.debug(`[hyperframes] ${e} swallowed:`,t)}var P=null;function T(e,t){if(P)try{P({source:"hf-preview",type:"analytics",event:e,properties:t??{}})}catch(o){A("runtime.analytics.site1",o)}}var w="data-hf-edit-base-x",y="data-hf-edit-base-y",_="data-hf-edit-original-translate",g=e=>{let t=parseFloat(e??"");return Number.isFinite(t)?t:0},H=e=>{let t=[],o=0,r="";for(let i of e.trim())i==="("&&(o+=1),i===")"&&(o=Math.max(0,o-1)),/\\s/.test(i)&&o===0?(r&&t.push(r),r=""):r+=i;return r&&t.push(r),t},R=/^-?(?:\\d+(?:\\.\\d+)?|\\.\\d+)px$/,b=(e,t)=>R.test(e)&&R.test(t)?`${parseFloat(e)+parseFloat(t)}px`:`calc(${e} + ${t})`,I=(e,t,o)=>{if(!e||e==="none")return`${t} ${o}`;let[r,i,a]=H(e);if(r===void 0)return`${t} ${o}`;if(i===void 0)return`${b(r,t)} ${o}`;let c=a===void 0?"":` ${a}`;return`${b(r,t)} ${b(i,o)}${c}`},O=e=>{try{e.ownerDocument.defaultView?.gsap?.getProperty?.(e,"x")}catch{}},j=e=>{let t=e.style.getPropertyValue("translate").trim();if(t)return t==="none"?"":t;try{let o=e.ownerDocument.defaultView,r=o?o.getComputedStyle(e).getPropertyValue("translate").trim():"";return r==="none"?"":r}catch{return""}},x=new WeakMap;function V(e,t){let o=x.get(e);if(!t?.force&&o!==void 0&&e.style.getPropertyValue("translate")!==o){T("position_edit_fold_skipped",{hfId:e.getAttribute("data-hf-id")});return}let r=g(e.getAttribute("data-x"))-g(e.getAttribute(w)),i=g(e.getAttribute("data-y"))-g(e.getAttribute(y));e.hasAttribute(_)||e.setAttribute(_,j(e)),o===void 0&&O(e);let a=e.getAttribute(_)??"",c=I(a,`${r}px`,`${i}px`);e.style.setProperty("translate",c),x.set(e,e.style.getPropertyValue("translate"))}function m(e){let t=e.querySelectorAll(`[${w}], [${y}]`),o=e.defaultView?.HTMLElement,r=0;for(let i=0;i<t.length;i++){let a=t[i];(o?a instanceof o:typeof a.style?.setProperty=="function")&&(V(a),r+=1)}return r}var D="__hfPositionEditsSeekReapplyWrapped",$=new WeakSet,F=new WeakMap,L=new WeakMap;function M(e){let t=e,o=()=>{try{m(t.document)}catch{}},r=n=>typeof n=="function"&&($.has(n)||!!n[D]),i=n=>{$.add(n);try{Object.defineProperty(n,D,{value:!0})}catch{}},a=n=>{if(typeof n!="function"||r(n))return n;let s=function(...u){let d=n.apply(this,u);return o(),d};return i(s),s},c=(n,s)=>{let u=F.get(n);if(u?.has(s))return!0;let d=Object.getOwnPropertyDescriptor(n,s);if(d?.configurable===!1){let l=n[s];return typeof l=="function"&&(n[s]=a(l),o()),!1}let p=n[s],f=d?.set;return Object.defineProperty(n,s,{configurable:!0,enumerable:d?.enumerable??!0,get:()=>p,set:l=>{p=a(l),f?.call(n,l)}}),p=a(p),u??(u=new Set),u.add(s),F.set(n,u),o(),!0},k=(n,s)=>{let u=L.get(t),d=Object.getOwnPropertyDescriptor(t,n);if(!u?.has(n)){if(d?.configurable===!1){let l=t[n];return l?c(l,s):!1}let f=t[n];Object.defineProperty(t,n,{configurable:!0,enumerable:d?.enumerable??!0,get:()=>f,set:l=>{f=l,f&&c(f,s)}}),u??(u=new Set),u.add(n),L.set(t,u)}let p=t[n];return p?c(p,s):!1},E=()=>{let n=k("__hf","seek"),s=k("__player","renderSeek");return n&&s};if(E())return;let S=120,h=t.setInterval(()=>{if(E()){t.clearInterval(h);return}S-=1,S<=0&&t.clearInterval(h)},50)}function W(){document.querySelector(`[${w}], [${y}]`)&&(m(document),M(window))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",W,{once:!0}):W();})();\n';
/** Returns the pre-built position-edits render IIFE as a string constant. */
export function getPositionEditsRenderScript(): string {
return POSITION_EDITS_RENDER_IIFE;
}
@@ -0,0 +1,304 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect } from "vitest";
import {
generateHyperframesHtml,
generateGsapTimelineScript,
generateHyperframesStyles,
} from "./hyperframes.js";
import { GSAP_CDN } from "../templates/constants.js";
import type { TimelineTextElement, TimelineMediaElement } from "../core.types";
function makeTextElement(overrides: Partial<TimelineTextElement> = {}): TimelineTextElement {
return {
id: "text-1",
type: "text",
name: "Title",
content: "Hello World",
startTime: 0,
duration: 5,
zIndex: 1,
...overrides,
};
}
function makeVideoElement(overrides: Partial<TimelineMediaElement> = {}): TimelineMediaElement {
return {
id: "vid-1",
type: "video",
name: "Background",
src: "video.mp4",
startTime: 0,
duration: 10,
zIndex: 0,
...overrides,
};
}
describe("generateHyperframesHtml", () => {
it("generates valid HTML with proper data attributes", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5);
expect(html).toContain("<!DOCTYPE html>");
expect(html).toContain("data-composition-id=");
expect(html).toContain("data-composition-duration=");
expect(html).toContain('id="stage"');
expect(html).toContain('id="stage-zoom-container"');
});
it("includes element data attributes", () => {
const elements = [makeTextElement({ id: "my-text", startTime: 2, duration: 3 })];
const html = generateHyperframesHtml(elements, 5);
expect(html).toContain('id="my-text"');
expect(html).toContain('data-start="2"');
expect(html).toContain('data-end="5"');
expect(html).toContain('data-layer="1"');
});
it("includes GSAP CDN script tag when includeScripts is true", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5, { includeScripts: true });
expect(html).toContain(`<script src="${GSAP_CDN}"></script>`);
});
it("does NOT include GSAP CDN by default", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5);
expect(html).not.toContain(GSAP_CDN);
});
it("includes timeline script when includeScripts is true", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5, { includeScripts: true });
expect(html).toContain("gsap.timeline({ paused: true })");
});
it("generates GSAP timeline with visibility animations when includeScripts is true", () => {
const elements = [makeTextElement({ id: "el-1", startTime: 1, duration: 3 })];
const html = generateHyperframesHtml(elements, 5, { includeScripts: true });
// Default animations include visibility bookends
expect(html).toContain('tl.set("#el-1"');
expect(html).toContain('visibility: "hidden"');
expect(html).toContain('visibility: "visible"');
});
it("sets resolution data attribute", () => {
const elements = [makeTextElement()];
const landscapeHtml = generateHyperframesHtml(elements, 5, { resolution: "landscape" });
expect(landscapeHtml).toContain('data-resolution="landscape"');
const portraitHtml = generateHyperframesHtml(elements, 5, { resolution: "portrait" });
expect(portraitHtml).toContain('data-resolution="portrait"');
});
it("generates video elements with proper tags", () => {
const elements = [makeVideoElement()];
const html = generateHyperframesHtml(elements, 10);
expect(html).toContain("<video");
expect(html).toContain('src="video.mp4"');
expect(html).toContain("playsinline");
});
it("generates text elements with content wrapper div", () => {
const elements = [makeTextElement({ content: "My Content" })];
const html = generateHyperframesHtml(elements, 5);
expect(html).toContain("<div>My Content</div>");
});
it("includes custom compositionId", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5, { compositionId: "test-comp-123" });
expect(html).toContain('data-composition-id="test-comp-123"');
});
it("calculates total duration from elements if they exceed provided duration", () => {
const elements = [makeTextElement({ startTime: 0, duration: 15 })];
const html = generateHyperframesHtml(elements, 5);
expect(html).toContain('data-composition-duration="15"');
});
it("includes style tags when includeStyles is true", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5, { includeStyles: true });
expect(html).toContain('<style data-hf-core="true">');
});
it("includes custom styles when provided", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5, {
styles: ".custom { color: red; }",
includeStyles: true,
});
expect(html).toContain('<style data-hf-custom="true">');
expect(html).toContain(".custom { color: red; }");
});
it("serializes keyframes as data attributes", () => {
const elements = [makeTextElement({ id: "text-kf" })];
const keyframes = {
"text-kf": [
{ id: "kf1", time: 0, properties: { opacity: 0 } },
{ id: "kf2", time: 1, properties: { opacity: 1 } },
],
};
const html = generateHyperframesHtml(elements, 5, { keyframes });
expect(html).toContain("data-keyframes=");
expect(html).toContain("kf1");
expect(html).toContain("kf2");
});
it("serializes zoom keyframes on zoom container", () => {
const elements = [makeTextElement()];
const stageZoomKeyframes = [
{ id: "z1", time: 0, zoom: { scale: 1, focusX: 960, focusY: 540 } },
{ id: "z2", time: 5, zoom: { scale: 2, focusX: 400, focusY: 300 } },
];
const html = generateHyperframesHtml(elements, 10, { stageZoomKeyframes });
expect(html).toContain("data-zoom-keyframes=");
});
it("includes x, y, scale data attributes for non-default values", () => {
const elements = [makeVideoElement({ x: 100, y: 200, scale: 1.5, opacity: 0.8 })];
const html = generateHyperframesHtml(elements, 10);
expect(html).toContain('data-x="100"');
expect(html).toContain('data-y="200"');
expect(html).toContain('data-scale="1.5"');
expect(html).toContain('data-opacity="0.8"');
});
it("omits x, y, scale, opacity data attributes when at default values", () => {
const elements = [makeVideoElement({ x: 0, y: 0, scale: 1, opacity: 1 })];
const html = generateHyperframesHtml(elements, 10);
expect(html).not.toContain("data-x=");
expect(html).not.toContain("data-y=");
expect(html).not.toContain("data-scale=");
expect(html).not.toContain("data-opacity=");
});
});
describe("generateGsapTimelineScript", () => {
it("generates a timeline script with visibility animations", () => {
const elements = [makeTextElement({ id: "el1", startTime: 1, duration: 4 })];
const script = generateGsapTimelineScript(elements, 5);
expect(script).toContain("const tl = gsap.timeline({ paused: true });");
expect(script).toContain('tl.set("#el1"');
expect(script).toContain('visibility: "hidden"');
expect(script).toContain('visibility: "visible"');
});
it("generates empty timeline for no elements", () => {
const script = generateGsapTimelineScript([], 5);
expect(script).toContain("const tl = gsap.timeline({ paused: true });");
expect(script).toContain("duration: 5");
});
it("includes media sync for video elements", () => {
const elements = [makeVideoElement()];
const script = generateGsapTimelineScript(elements, 10);
expect(script).toContain("Sync media playback");
expect(script).toContain("media.currentTime");
});
it("generates initial position sets for elements with x/y offsets", () => {
const elements = [makeVideoElement({ id: "vid-pos", x: 100, y: 200 })];
const script = generateGsapTimelineScript(elements, 10);
expect(script).toContain('tl.set("#vid-pos", { x: 100, y: 200 }');
});
it("generates animations from keyframes", () => {
const elements = [makeTextElement({ id: "el-kf", startTime: 0, duration: 5 })];
const keyframes = {
"el-kf": [
{ id: "kf1", time: 0, properties: { opacity: 0 } },
{ id: "kf2", time: 1, properties: { opacity: 1 } },
],
};
const script = generateGsapTimelineScript(elements, 5, { keyframes });
// Should contain keyframe-based animations
expect(script).toContain("el-kf");
});
});
describe("generateHyperframesStyles", () => {
it("generates core CSS with stage dimensions for landscape", () => {
const elements = [makeTextElement()];
const { coreCss } = generateHyperframesStyles(elements, "landscape");
expect(coreCss).toContain("width: 1920px");
expect(coreCss).toContain("height: 1080px");
expect(coreCss).toContain("#stage");
});
it("generates core CSS with stage dimensions for portrait", () => {
const elements = [makeTextElement()];
const { coreCss } = generateHyperframesStyles(elements, "portrait");
expect(coreCss).toContain("width: 1080px");
expect(coreCss).toContain("height: 1920px");
});
it("generates element-specific styles for text", () => {
const elements = [makeTextElement({ id: "styled-text", fontSize: 72, color: "red" })];
const { coreCss } = generateHyperframesStyles(elements, "landscape");
expect(coreCss).toContain("#styled-text");
expect(coreCss).toContain("position: absolute");
});
it("generates element-specific styles for video", () => {
const elements = [makeVideoElement({ id: "vid-styled" })];
const { coreCss } = generateHyperframesStyles(elements, "landscape");
expect(coreCss).toContain("#vid-styled");
expect(coreCss).toContain("object-fit: contain");
});
it("includes custom CSS when provided", () => {
const elements = [makeTextElement()];
const { customCss } = generateHyperframesStyles(
elements,
"landscape",
".custom { color: blue; }",
);
expect(customCss).toContain(".custom { color: blue; }");
});
it("generates Google Fonts link for Inter (always included)", () => {
const elements = [makeTextElement()];
const { googleFontsLink } = generateHyperframesStyles(elements, "landscape");
expect(googleFontsLink).toContain("fonts.googleapis.com");
expect(googleFontsLink).toContain("Inter");
});
it("includes additional font families from text elements", () => {
const elements = [makeTextElement({ fontFamily: "Montserrat" })];
const { googleFontsLink } = generateHyperframesStyles(elements, "landscape");
expect(googleFontsLink).toContain("Montserrat");
});
});
+768
View File
@@ -0,0 +1,768 @@
import type { TimelineElement, CanvasResolution, Keyframe, StageZoomKeyframe } from "../core.types";
import {
CANVAS_DIMENSIONS,
isTextElement,
isMediaElement,
isCompositionElement,
} from "../core.types";
import type { GsapAnimation } from "@hyperframes/parsers";
import { serializeGsapAnimations, keyframesToGsapAnimations } from "@hyperframes/parsers";
import { GSAP_CDN, BASE_STYLES, ZOOM_CONTAINER_STYLES } from "../templates/constants";
const GOOGLE_FONTS_BASE = "https://fonts.googleapis.com/css2";
const FONT_WEIGHTS: Record<string, string> = {
Inter: "400;500;600;700;800;900",
Roboto: "400;500;700;900",
Montserrat: "400;500;600;700;800;900",
Poppins: "400;500;600;700;800;900",
"Bebas Neue": "400",
Oswald: "400;500;600;700",
Anton: "400",
"Playfair Display": "400;500;600;700;800;900",
Lora: "400;500;600;700",
Pacifico: "400",
"Permanent Marker": "400",
"Fira Code": "400;500;600;700",
};
function generateGoogleFontsUrl(fontFamilies: string[]): string | null {
if (fontFamilies.length === 0) return null;
const families = fontFamilies
.filter((f) => f in FONT_WEIGHTS)
.map((f) => {
const weights = FONT_WEIGHTS[f];
const encodedName = f.replace(/ /g, "+");
return `family=${encodedName}:wght@${weights}`;
});
if (families.length === 0) return null;
return `${GOOGLE_FONTS_BASE}?${families.join("&")}&display=swap`;
}
export interface SerializeOptions {
animations?: GsapAnimation[];
styles?: string;
generateDefaultAnimations?: boolean;
resolution?: CanvasResolution;
compositionId?: string;
keyframes?: Record<string, Keyframe[]>;
stageZoomKeyframes?: StageZoomKeyframe[];
includeScripts?: boolean;
includeStyles?: boolean;
}
/**
* Stage Positioning Conventions:
*
* 1. All elements are absolutely positioned relative to the #stage container
* 2. The #stage has position: relative and fixed dimensions (1920x1080 or 1080x1920)
* 3. Elements start with opacity: 0 and are revealed via GSAP animations
*
* Media Elements (video, image):
* - position: absolute (relative to #stage)
* - width: 100%, height: 100% (fill the stage)
* - object-fit: contain (preserve aspect ratio, centered, no cropping)
* - This ensures media is always visible and centered within the stage
*
* Text Elements:
* - position: absolute, width/height: 100%
* - Inner div uses flexbox to center content (selected via > div)
*
* Audio Elements:
* - position: absolute (invisible, for timing only)
*/
function sortElements(elements: TimelineElement[]): TimelineElement[] {
return [...elements].sort((a, b) => {
if (a.zIndex !== b.zIndex) {
return (a.zIndex ?? 0) - (b.zIndex ?? 0);
}
return a.startTime - b.startTime;
});
}
export function generateHyperframesStyles(
elements: TimelineElement[],
resolution: CanvasResolution,
customStyles?: string,
): { coreCss: string; customCss: string; googleFontsLink: string } {
const { width, height } = CANVAS_DIMENSIONS[resolution];
const sortedElements = sortElements(elements);
const elementStyles = sortedElements.map((el) => generateElementStyles(el)).join("\n");
// Collect unique font families from text elements
const usedFonts = new Set<string>();
for (const el of sortedElements) {
if (isTextElement(el) && el.fontFamily) {
usedFonts.add(el.fontFamily);
}
}
// Always include Inter as the default
usedFonts.add("Inter");
const googleFontsUrl = generateGoogleFontsUrl([...usedFonts]);
const googleFontsLink = googleFontsUrl
? `<link data-hf-fonts="true" rel="preconnect" href="https://fonts.googleapis.com">
<link data-hf-fonts="true" rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link data-hf-fonts="true" href="${googleFontsUrl}" rel="stylesheet">`
: "";
const coreCss = `${BASE_STYLES}
#stage { position: relative; width: ${width}px; height: ${height}px; overflow: hidden; background: #fff; }
#stage-zoom-container { ${ZOOM_CONTAINER_STYLES} }
${elementStyles}`.trim();
const customCss = customStyles?.trim() ? customStyles.trim() : "";
return { coreCss, customCss, googleFontsLink };
}
function generateElementStyles(element: TimelineElement): string {
const baseStyles = "position: absolute;";
if (isTextElement(element)) {
const fontSize = element.fontSize ?? 48;
const fontWeight = element.fontWeight ?? 700;
const fontFamily = element.fontFamily ?? "Inter";
const color = element.color ?? "white";
const textShadow =
element.textShadow !== false ? "text-shadow: 2px 2px 4px rgba(0,0,0,0.8);" : "";
// Text outline using -webkit-text-stroke
const textOutline = element.textOutline
? `-webkit-text-stroke: ${element.textOutlineWidth ?? 2}px ${
element.textOutlineColor ?? "#000000"
}; paint-order: stroke fill;`
: "";
// Text highlight using background
const textHighlight = element.textHighlight
? `background-color: ${element.textHighlightColor ?? "yellow"}; padding: ${element.textHighlightPadding ?? 4}px ${
(element.textHighlightPadding ?? 4) * 1.5
}px; border-radius: ${
element.textHighlightRadius ?? 4
}px; box-decoration-break: clone; -webkit-box-decoration-break: clone;`
: "";
return ` #${element.id} { ${baseStyles} width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; pointer-events: none; }
#${element.id} > div { font-family: '${fontFamily}', sans-serif; font-size: ${fontSize}px; font-weight: ${fontWeight}; color: ${color}; ${textShadow} ${textOutline} ${textHighlight} pointer-events: auto; cursor: grab; white-space: pre-wrap; text-align: center; }`;
}
switch (element.type) {
case "video":
// Videos fill the stage with standard CSS positioning (0,0 = top-left)
return ` #${element.id} { ${baseStyles} width: 100%; height: 100%; object-fit: contain; transform-origin: center center; }`;
case "image":
// Images use standard CSS positioning (0,0 = top-left)
return ` #${element.id} { ${baseStyles} max-width: 100%; max-height: 100%; transform-origin: center center; }`;
case "audio":
return ` #${element.id} { ${baseStyles} }`;
case "composition":
// Compositions use standard CSS positioning (0,0 = top-left)
return ` #${element.id} { ${baseStyles} width: 100%; height: 100%; position: absolute; }`;
}
}
export function generateGsapTimelineScript(
elements: TimelineElement[],
totalDuration: number,
options: SerializeOptions = {},
): string {
const {
animations,
generateDefaultAnimations = true,
resolution = "landscape",
keyframes,
stageZoomKeyframes,
} = options;
const { width, height } = CANVAS_DIMENSIONS[resolution];
const sortedElements = sortElements(elements);
const hasMedia = sortedElements.some((el) => el.type === "video" || el.type === "audio");
// Convert keyframes to GSAP animations
let keyframeAnimations: GsapAnimation[] = [];
if (keyframes) {
for (const element of sortedElements) {
const elementKeyframes = keyframes[element.id];
if (elementKeyframes && elementKeyframes.length > 0) {
const baseScale =
isMediaElement(element) || isCompositionElement(element) ? (element.scale ?? 1) : 1;
const converted = keyframesToGsapAnimations(
element.id,
elementKeyframes,
element.startTime,
{
x: element.x ?? 0,
y: element.y ?? 0,
scale: baseScale,
},
);
keyframeAnimations = keyframeAnimations.concat(converted);
}
}
}
// Generate zoom keyframes GSAP animations
const zoomAnimations = generateZoomGsapAnimations(stageZoomKeyframes || [], width, height);
// Generate initial position/scale set() calls for all elements
// This must be included regardless of keyframe animations
const initialPositionSets = generateInitialPositionSets(sortedElements, keyframes);
// Generate visibility animations for elements without keyframes
// When using keyframes path, elements without keyframes need explicit visibility
const visibilityAnimations = generateVisibilityForElementsWithoutKeyframes(
sortedElements,
keyframes,
);
let gsapScript: string;
if (animations && animations.length > 0) {
// Merge provided animations with keyframe animations
const allAnimations = [...animations, ...keyframeAnimations];
gsapScript = serializeGsapAnimations(allAnimations, "tl", {
includeMediaSync: hasMedia,
});
// Prepend initial positions and visibility for elements without keyframes, append zoom animations
const prependAnimations = [initialPositionSets, visibilityAnimations]
.filter(Boolean)
.join("\n");
if (prependAnimations) {
gsapScript = gsapScript.replace(
"const tl = gsap.timeline({ paused: true });",
`const tl = gsap.timeline({ paused: true });\n${prependAnimations}`,
);
}
if (zoomAnimations) {
gsapScript += "\n" + zoomAnimations;
}
} else if (keyframeAnimations.length > 0) {
// Use only keyframe animations
gsapScript = serializeGsapAnimations(keyframeAnimations, "tl", {
includeMediaSync: hasMedia,
});
// Prepend initial positions and visibility for elements without keyframes, append zoom animations
const prependAnimations = [initialPositionSets, visibilityAnimations]
.filter(Boolean)
.join("\n");
if (prependAnimations) {
gsapScript = gsapScript.replace(
"const tl = gsap.timeline({ paused: true });",
`const tl = gsap.timeline({ paused: true });\n${prependAnimations}`,
);
}
if (zoomAnimations) {
gsapScript += "\n" + zoomAnimations;
}
} else if (generateDefaultAnimations) {
gsapScript = generateDefaultGsapAnimations(
sortedElements,
totalDuration,
stageZoomKeyframes,
width,
height,
);
} else {
gsapScript = `
const tl = gsap.timeline({ paused: true });
${initialPositionSets ? initialPositionSets + "\n" : ""} tl.to({}, { duration: ${totalDuration || 1} });
`;
// Append zoom animations
if (zoomAnimations) {
gsapScript += "\n" + zoomAnimations;
}
}
return gsapScript;
}
export function generateHyperframesHtml(
elements: TimelineElement[],
totalDuration: number,
options: SerializeOptions = {},
): string {
const {
animations,
styles,
generateDefaultAnimations = true,
resolution = "landscape",
compositionId = `comp-${Date.now()}`,
keyframes,
stageZoomKeyframes,
includeScripts = false,
includeStyles = false,
} = options;
// Include zoom keyframes in duration calculation
const maxZoomTime =
stageZoomKeyframes && stageZoomKeyframes.length > 0
? Math.max(...stageZoomKeyframes.map((kf) => kf.time))
: 0;
const calculatedDuration =
elements.length > 0
? Math.max(...elements.map((el) => el.startTime + el.duration), totalDuration, maxZoomTime)
: Math.max(totalDuration, maxZoomTime);
const sortedElements = sortElements(elements);
const elementsHtml = sortedElements
.map((el) => generateElementHtml(el, keyframes?.[el.id]))
.join("\n ");
const customStyles = styles || "";
// Serialize zoom keyframes to data attribute
const zoomKeyframesAttr =
stageZoomKeyframes && stageZoomKeyframes.length > 0
? ` data-zoom-keyframes='${JSON.stringify(stageZoomKeyframes).replace(/'/g, "&#39;")}'`
: "";
let styleTags = "";
let googleFontsLink = "";
if (includeStyles) {
const styles = generateHyperframesStyles(sortedElements, resolution, customStyles);
googleFontsLink = styles.googleFontsLink;
styleTags = [
styles.coreCss
? ` <style data-hf-core="true">
${styles.coreCss.split("\n").join("\n ")}
</style>`
: "",
styles.customCss
? ` <style data-hf-custom="true">
${styles.customCss.split("\n").join("\n ")}
</style>`
: "",
]
.filter(Boolean)
.join("\n");
}
const gsapScript = includeScripts
? generateGsapTimelineScript(sortedElements, totalDuration, {
animations,
generateDefaultAnimations,
resolution,
keyframes,
stageZoomKeyframes,
})
: "";
const gsapCdnTag = includeScripts ? ` <script src="${GSAP_CDN}"></script>` : "";
const gsapScriptTag = includeScripts
? ` <script>
${gsapScript}
</script>`
: "";
const customStylesAttr = customStyles
? ` data-custom-styles='${JSON.stringify(customStyles).replace(/'/g, "&#39;")}'`
: "";
const resolutionAttr = ` data-resolution="${resolution}"`;
return `<!DOCTYPE html>
<html data-composition-id="${compositionId}" data-composition-duration="${calculatedDuration}"${resolutionAttr}${customStylesAttr}>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${googleFontsLink}
${gsapCdnTag}
${styleTags ? ` ${styleTags}` : ""}
</head>
<body>
<div id="stage">
<div id="stage-zoom-container"${zoomKeyframesAttr}>
${elementsHtml}
</div>
</div>
${gsapScriptTag}
</body>
</html>`;
}
function calculateZoomTransform(
scale: number,
focusX: number,
focusY: number,
canvasWidth: number,
canvasHeight: number,
): { x: number; y: number } {
const centerX = canvasWidth / 2;
const centerY = canvasHeight / 2;
const x = centerX - focusX * scale;
const y = centerY - focusY * scale;
return { x, y };
}
function generateZoomGsapAnimations(
zoomKeyframes: StageZoomKeyframe[],
canvasWidth: number,
canvasHeight: number,
): string {
if (!zoomKeyframes || zoomKeyframes.length === 0) {
return "";
}
const sortedKeyframes = [...zoomKeyframes].sort((a, b) => a.time - b.time);
const animations: string[] = [];
animations.push(" // Stage zoom animations");
for (let i = 0; i < sortedKeyframes.length; i++) {
const kf = sortedKeyframes[i];
if (!kf) continue;
const { x, y } = calculateZoomTransform(
kf.zoom.scale,
kf.zoom.focusX,
kf.zoom.focusY,
canvasWidth,
canvasHeight,
);
if (i === 0) {
animations.push(
` tl.set("#stage-zoom-container", { scale: ${kf.zoom.scale}, x: ${x}, y: ${y} }, ${kf.time});`,
);
} else {
const prevKf = sortedKeyframes[i - 1];
if (!prevKf) continue;
const duration = kf.time - prevKf.time;
const ease = kf.ease ? `, ease: "${kf.ease}"` : "";
animations.push(
` tl.to("#stage-zoom-container", { scale: ${kf.zoom.scale}, x: ${x}, y: ${y}, duration: ${duration}${ease} }, ${prevKf.time});`,
);
}
}
return animations.join("\n");
}
function generateElementHtml(element: TimelineElement, keyframes?: Keyframe[]): string {
const baseAttrs = [
`id="${element.id}"`,
`data-hf-id="${element.id}"`,
`data-start="${element.startTime}"`,
`data-end="${element.startTime + element.duration}"`,
`data-layer="${element.zIndex}"`,
`data-name="${element.name}"`,
];
// Serialize transform properties (x, y, scale, opacity) if non-default
if (element.x !== undefined && element.x !== 0) {
baseAttrs.push(`data-x="${element.x}"`);
}
if (element.y !== undefined && element.y !== 0) {
baseAttrs.push(`data-y="${element.y}"`);
}
if (element.scale !== undefined && element.scale !== 1) {
baseAttrs.push(`data-scale="${element.scale}"`);
}
if (element.opacity !== undefined && element.opacity !== 1) {
baseAttrs.push(`data-opacity="${element.opacity}"`);
}
// Serialize keyframes to data attribute if present
if (keyframes && keyframes.length > 0) {
const kfJson = JSON.stringify(keyframes);
baseAttrs.push(`data-keyframes='${kfJson.replace(/'/g, "&#39;")}'`);
}
if (isTextElement(element)) {
const textAttrs = [...baseAttrs, `data-type="text"`];
if (element.color) {
textAttrs.push(`data-color="${element.color}"`);
}
if (element.fontSize) {
textAttrs.push(`data-font-size="${element.fontSize}"`);
}
if (element.fontWeight) {
textAttrs.push(`data-font-weight="${element.fontWeight}"`);
}
if (element.fontFamily) {
textAttrs.push(`data-font-family="${element.fontFamily}"`);
}
if (element.textShadow === false) {
textAttrs.push(`data-text-shadow="false"`);
}
if (element.textOutline) {
textAttrs.push(`data-text-outline="true"`);
if (element.textOutlineColor) {
textAttrs.push(`data-text-outline-color="${element.textOutlineColor}"`);
}
if (element.textOutlineWidth) {
textAttrs.push(`data-text-outline-width="${element.textOutlineWidth}"`);
}
}
if (element.textHighlight) {
textAttrs.push(`data-text-highlight="true"`);
if (element.textHighlightColor) {
textAttrs.push(`data-text-highlight-color="${element.textHighlightColor}"`);
}
if (element.textHighlightPadding) {
textAttrs.push(`data-text-highlight-padding="${element.textHighlightPadding}"`);
}
if (element.textHighlightRadius) {
textAttrs.push(`data-text-highlight-radius="${element.textHighlightRadius}"`);
}
}
const content = element.content || element.name;
return `<div ${textAttrs.join(" ")}><div>${content}</div></div>`;
}
if (isCompositionElement(element)) {
const compositionAttrs = [
...baseAttrs,
`data-type="composition"`,
`data-composition-id="${element.compositionId}"`,
];
if (element.sourceDuration) {
compositionAttrs.push(`data-source-duration="${element.sourceDuration}"`);
}
if (element.sourceWidth) {
compositionAttrs.push(`data-source-width="${element.sourceWidth}"`);
}
if (element.sourceHeight) {
compositionAttrs.push(`data-source-height="${element.sourceHeight}"`);
}
if (element.variableValues && Object.keys(element.variableValues).length > 0) {
const varJson = JSON.stringify(element.variableValues);
compositionAttrs.push(`data-variable-values='${varJson.replace(/'/g, "&#39;")}'`);
}
const attrs = compositionAttrs.join(" ");
// Build iframe src with variable values as query params if present
// Strip any existing query params first to avoid duplication
let iframeSrc = element.src.split("?")[0];
if (element.variableValues && Object.keys(element.variableValues).length > 0) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(element.variableValues)) {
params.set(key, String(value));
}
iframeSrc = `${iframeSrc}?${params.toString()}`;
}
// Motion designs are full-screen overlays - always use 100% sizing
// The motion design HTML handles its own internal positioning
// Wrap iframe in container with click overlay for selection
return `<div ${attrs} style="width: 100%; height: 100%;">
<iframe src="${iframeSrc}" sandbox="allow-scripts allow-same-origin" style="width: 100%; height: 100%; border: none; pointer-events: none;"></iframe>
<div class="composition-click-overlay" style="position: absolute; inset: 0; cursor: pointer;"></div>
</div>`;
}
if (isMediaElement(element)) {
if (element.mediaStartTime) {
baseAttrs.push(`data-media-start="${element.mediaStartTime}"`);
}
if (element.sourceDuration) {
baseAttrs.push(`data-source-duration="${element.sourceDuration}"`);
}
if (element.isAroll) {
baseAttrs.push(`data-aroll="true"`);
}
if (element.volume !== undefined && element.volume !== 1) {
baseAttrs.push(`data-volume="${element.volume}"`);
}
if (element.type === "video" && element.hasAudio) {
baseAttrs.push(`data-has-audio="true"`);
}
}
const attrs = baseAttrs.join(" ");
switch (element.type) {
case "video":
return `<video ${attrs} src="${element.src}" playsinline></video>`;
case "image":
return `<img ${attrs} src="${element.src}" alt="${element.name}" />`;
case "audio":
return `<audio ${attrs} src="${element.src}"></audio>`;
default:
return "";
}
}
/**
* Generate initial position sets for elements.
*
* Center-based coordinate system with standard CSS origin:
* - (0, 0) = top-left corner of the canvas
* - (960, 540) = center of canvas (landscape 1920x1080)
* - x/y specifies where the element's CENTER goes (not top-left corner)
*
* Note: xPercent: -50, yPercent: -50 is applied once at player init via
* _initializeElementCentering(), so we only set x, y, scale here.
* This keeps generated timeline code clean (no repeated xPercent/yPercent).
*/
function generateInitialPositionSets(
elements: TimelineElement[],
keyframes?: Record<string, Keyframe[]>,
): string {
const sets: string[] = [];
const timeEpsilon = 0.001;
for (const el of elements) {
const elementKeyframes = keyframes?.[el.id];
const hasBaseKeyframe = elementKeyframes?.some(
(kf) =>
Math.abs(kf.time) <= timeEpsilon &&
(kf.properties.x !== undefined ||
kf.properties.y !== undefined ||
kf.properties.scale !== undefined),
);
const xVal = el.x ?? 0;
const yVal = el.y ?? 0;
const scaleVal = isMediaElement(el) ? (el.scale ?? 1) : 1;
// Audio elements don't need positioning
if (el.type === "audio") continue;
// Composition elements (motion designs) are full-screen overlays
// They don't need x/y/scale positioning - the HTML handles internal layout
if (isCompositionElement(el)) continue;
// Skip if element has a base keyframe that will handle positioning
if (hasBaseKeyframe) {
continue;
}
// Set position and scale (xPercent/yPercent applied at player init)
if (scaleVal !== 1) {
sets.push(` tl.set("#${el.id}", { x: ${xVal}, y: ${yVal}, scale: ${scaleVal} }, 0);`);
} else if (xVal !== 0 || yVal !== 0) {
sets.push(` tl.set("#${el.id}", { x: ${xVal}, y: ${yVal} }, 0);`);
}
}
return sets.length > 0 ? sets.join("\n") : "";
}
/**
* Generates visibility bookends for ALL elements to ensure they appear/disappear
* at the correct times. Elements with keyframes still need visibility bookends
* because keyframesToGsapAnimations only handles property animations, not visibility.
*
* If opacity keyframes exist, the first keyframe defines the base.
*/
function generateVisibilityForElementsWithoutKeyframes(
elements: TimelineElement[],
keyframes?: Record<string, Keyframe[]>,
): string {
const animations: string[] = [];
for (const el of elements) {
const elementKeyframes = keyframes?.[el.id];
const opacityKeyframes =
elementKeyframes?.filter((kf) => kf.properties.opacity !== undefined) || [];
const start = el.startTime;
const end = el.startTime + el.duration;
const safeName = el.name.replace(/[\r\n]+/g, " ");
animations.push(` // ${safeName} (visibility)`);
animations.push(` tl.set("#${el.id}", { visibility: "hidden" }, 0);`);
let elementOpacity = el.opacity ?? 1;
if (opacityKeyframes.length > 0) {
const firstOpacityKeyframe = [...opacityKeyframes].sort((a, b) => a.time - b.time)[0];
if (firstOpacityKeyframe?.properties.opacity !== undefined) {
elementOpacity = firstOpacityKeyframe.properties.opacity;
}
}
// Only include opacity in visibility bookend if non-default or has opacity keyframes
const needsOpacity = elementOpacity !== 1 || opacityKeyframes.length > 0;
if (needsOpacity) {
animations.push(
` tl.set("#${el.id}", { visibility: "visible", opacity: ${elementOpacity} }, ${start});`,
);
} else {
animations.push(` tl.set("#${el.id}", { visibility: "visible" }, ${start});`);
}
animations.push(` tl.set("#${el.id}", { visibility: "hidden" }, ${end});`);
}
return animations.length > 0 ? animations.join("\n") : "";
}
function generateDefaultGsapAnimations(
elements: TimelineElement[],
totalDuration: number,
stageZoomKeyframes?: StageZoomKeyframe[],
canvasWidth?: number,
canvasHeight?: number,
): string {
if (elements.length === 0 && (!stageZoomKeyframes || stageZoomKeyframes.length === 0)) {
return `
const tl = gsap.timeline({ paused: true });
tl.to({}, { duration: ${totalDuration || 1} });
`;
}
const animations: string[] = [];
// First, set initial positions and scales for elements with x/y offsets or scale
const initialPositionSets = generateInitialPositionSets(elements);
if (initialPositionSets) {
animations.push(initialPositionSets);
}
for (const el of elements) {
const start = el.startTime;
const end = el.startTime + el.duration;
const safeName = el.name.replace(/[\r\n]+/g, " ");
const elementOpacity = el.opacity ?? 1;
animations.push(` // ${safeName}`);
animations.push(` tl.set("#${el.id}", { visibility: "hidden" }, 0);`);
// Only include opacity if non-default
if (elementOpacity !== 1) {
animations.push(
` tl.set("#${el.id}", { visibility: "visible", opacity: ${elementOpacity} }, ${start});`,
);
} else {
animations.push(` tl.set("#${el.id}", { visibility: "visible" }, ${start});`);
}
animations.push(` tl.set("#${el.id}", { visibility: "hidden" }, ${end});`);
}
const mediaElements = elements.filter((el) => el.type === "video" || el.type === "audio");
const mediaSync =
mediaElements.length > 0
? `
// Sync media playback
tl.eventCallback("onUpdate", function() {
const time = tl.time();
document.querySelectorAll("video[data-start], audio[data-start]").forEach(function(media) {
const start = parseFloat(media.dataset.start);
const end = parseFloat(media.dataset.end) || Infinity;
const mediaTime = time - start;
if (time >= start && time < end) {
if (Math.abs(media.currentTime - mediaTime) > 0.1) {
media.currentTime = mediaTime;
}
if (media.paused && !tl.paused()) {
media.play().catch(function() {});
}
} else if (!media.paused) {
media.pause();
}
});
});`
: "";
// Generate zoom animations if present
const zoomAnimations =
stageZoomKeyframes && stageZoomKeyframes.length > 0 && canvasWidth && canvasHeight
? "\n" + generateZoomGsapAnimations(stageZoomKeyframes, canvasWidth, canvasHeight)
: "";
return `
const tl = gsap.timeline({ paused: true });
${animations.join("\n")}
${mediaSync}${zoomAnimations}
`;
}
+185
View File
@@ -0,0 +1,185 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import * as core from "./index.js";
describe("@hyperframes/core public API exports", () => {
describe("type-related constants and utilities", () => {
it("exports CANVAS_DIMENSIONS", () => {
expect(core.CANVAS_DIMENSIONS).toBeDefined();
expect(core.CANVAS_DIMENSIONS.landscape).toEqual({ width: 1920, height: 1080 });
expect(core.CANVAS_DIMENSIONS.portrait).toEqual({ width: 1080, height: 1920 });
expect(core.CANVAS_DIMENSIONS["landscape-4k"]).toEqual({ width: 3840, height: 2160 });
expect(core.CANVAS_DIMENSIONS["portrait-4k"]).toEqual({ width: 2160, height: 3840 });
expect(core.CANVAS_DIMENSIONS.square).toEqual({ width: 1080, height: 1080 });
expect(core.CANVAS_DIMENSIONS["square-4k"]).toEqual({ width: 2160, height: 2160 });
});
it("exports VALID_CANVAS_RESOLUTIONS derived from CANVAS_DIMENSIONS", () => {
expect(core.VALID_CANVAS_RESOLUTIONS).toEqual([
"landscape",
"portrait",
"landscape-4k",
"portrait-4k",
"square",
"square-4k",
]);
});
it("exports normalizeResolutionFlag with alias support", () => {
expect(core.normalizeResolutionFlag("4k")).toBe("landscape-4k");
expect(core.normalizeResolutionFlag("uhd")).toBe("landscape-4k");
expect(core.normalizeResolutionFlag("1080p")).toBe("landscape");
expect(core.normalizeResolutionFlag("landscape-4k")).toBe("landscape-4k");
expect(core.normalizeResolutionFlag("UHD")).toBe("landscape-4k");
expect(core.normalizeResolutionFlag("square")).toBe("square");
expect(core.normalizeResolutionFlag("square-4k")).toBe("square-4k");
expect(core.normalizeResolutionFlag("1080p-square")).toBe("square");
expect(core.normalizeResolutionFlag("4k-square")).toBe("square-4k");
expect(core.normalizeResolutionFlag("8k")).toBeUndefined();
expect(core.normalizeResolutionFlag(undefined)).toBeUndefined();
});
it("exports TIMELINE_COLORS", () => {
expect(core.TIMELINE_COLORS).toBeDefined();
expect(core.TIMELINE_COLORS.video).toBeDefined();
expect(core.TIMELINE_COLORS.image).toBeDefined();
expect(core.TIMELINE_COLORS.text).toBeDefined();
expect(core.TIMELINE_COLORS.audio).toBeDefined();
expect(core.TIMELINE_COLORS.composition).toBeDefined();
});
it("exports DEFAULT_DURATIONS", () => {
expect(core.DEFAULT_DURATIONS).toBeDefined();
expect(core.DEFAULT_DURATIONS.video).toBe(5);
expect(core.DEFAULT_DURATIONS.text).toBe(2);
});
it("exports type guard functions", () => {
expect(typeof core.isTextElement).toBe("function");
expect(typeof core.isMediaElement).toBe("function");
expect(typeof core.isCompositionElement).toBe("function");
});
it("exports getDefaultStageZoom", () => {
expect(typeof core.getDefaultStageZoom).toBe("function");
const zoom = core.getDefaultStageZoom("landscape");
expect(zoom.scale).toBe(1);
expect(zoom.focusX).toBe(960);
expect(zoom.focusY).toBe(540);
});
});
describe("template exports", () => {
it("exports generateBaseHtml", () => {
expect(typeof core.generateBaseHtml).toBe("function");
});
it("exports getStageStyles", () => {
expect(typeof core.getStageStyles).toBe("function");
});
it("exports template constants", () => {
expect(core.GSAP_CDN).toBeDefined();
expect(core.BASE_STYLES).toBeDefined();
expect(core.ELEMENT_BASE_STYLES).toBeDefined();
expect(core.MEDIA_STYLES).toBeDefined();
expect(core.TEXT_STYLES).toBeDefined();
expect(core.ZOOM_CONTAINER_STYLES).toBeDefined();
});
});
describe("parser exports", () => {
it("does NOT re-export GSAP parser functions from barrel (available via gsap-parser subpath)", () => {
// GSAP AST parser functions are not re-exported from the barrel —
// use the acorn parser (gsapParserAcorn) or writer (gsapWriterAcorn) directly.
expect(typeof (core as Record<string, unknown>).parseGsapScript).toBe("undefined");
});
it("does NOT re-export GSAP constants from barrel (available via gsap-constants subpath)", () => {
expect((core as Record<string, unknown>).SUPPORTED_PROPS).toBeUndefined();
});
it("exports HTML parser functions", () => {
expect(typeof core.parseHtml).toBe("function");
expect(typeof core.updateElementInHtml).toBe("function");
expect(typeof core.addElementToHtml).toBe("function");
expect(typeof core.removeElementFromHtml).toBe("function");
expect(typeof core.validateCompositionHtml).toBe("function");
expect(typeof core.extractCompositionMetadata).toBe("function");
});
});
describe("generator exports", () => {
it("exports hyperframes generator functions", () => {
expect(typeof core.generateHyperframesHtml).toBe("function");
expect(typeof core.generateGsapTimelineScript).toBe("function");
expect(typeof core.generateHyperframesStyles).toBe("function");
});
});
describe("compiler exports", () => {
it("exports compiler functions", () => {
expect(typeof core.compileTimingAttrs).toBe("function");
expect(typeof core.injectDurations).toBe("function");
expect(typeof core.extractResolvedMedia).toBe("function");
expect(typeof core.clampDurations).toBe("function");
expect(typeof core.shouldClampMediaDuration).toBe("function");
});
});
describe("lint exports", () => {
it("exposes lintHyperframeHtml via the @hyperframes/core/lint back-compat stub", async () => {
// Lint moved to @hyperframes/lint; core's main entry no longer re-exports
// it (that would cycle through the lint package). The subpath stub keeps
// existing @hyperframes/core/lint imports working.
const lint = await import("./lint/index.js");
expect(typeof lint.lintHyperframeHtml).toBe("function");
});
});
describe("media exports", () => {
it("exports parseAnimatedGifMetadata", () => {
expect(typeof core.parseAnimatedGifMetadata).toBe("function");
});
});
describe("inline-script exports", () => {
it("exports hyperframe runtime artifacts", () => {
expect(core.HYPERFRAME_RUNTIME_ARTIFACTS).toBeDefined();
expect(core.HYPERFRAME_RUNTIME_CONTRACT).toBeDefined();
expect(typeof core.loadHyperframeRuntimeSource).toBe("function");
});
it("exports runtime contract constants", () => {
expect(core.HYPERFRAME_RUNTIME_GLOBALS).toBeDefined();
expect(core.HYPERFRAME_BRIDGE_SOURCES).toBeDefined();
expect(core.HYPERFRAME_CONTROL_ACTIONS).toBeDefined();
});
it("exports buildHyperframesRuntimeScript", () => {
expect(typeof core.buildHyperframesRuntimeScript).toBe("function");
});
it("exports MEDIA_VISUAL_STYLE_PROPERTIES", () => {
expect(core.MEDIA_VISUAL_STYLE_PROPERTIES).toBeDefined();
expect(Array.isArray(core.MEDIA_VISUAL_STYLE_PROPERTIES)).toBe(true);
expect(core.MEDIA_VISUAL_STYLE_PROPERTIES).toContain("width");
expect(core.MEDIA_VISUAL_STYLE_PROPERTIES).toContain("opacity");
expect(core.MEDIA_VISUAL_STYLE_PROPERTIES).toContain("transform");
});
it("exports copyMediaVisualStyles", () => {
expect(typeof core.copyMediaVisualStyles).toBe("function");
});
it("exports quantizeTimeToFrame", () => {
expect(typeof core.quantizeTimeToFrame).toBe("function");
});
});
describe("adapter exports", () => {
it("exports createGSAPFrameAdapter", () => {
expect(typeof core.createGSAPFrameAdapter).toBe("function");
});
});
});

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