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
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:
@@ -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
|
||||
@@ -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]`
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { FrameAdapter, FrameAdapterContext } from "./types";
|
||||
export type { GSAPTimelineLike, CreateGSAPFrameAdapterOptions } from "./gsap";
|
||||
export { createGSAPFrameAdapter } from "./gsap";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 0–1 (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 (0–1) 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 60–120 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 90–180; 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./beatDetection.js";
|
||||
export * from "./beatFile.js";
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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(""banner"");
|
||||
expect(html).not.toContain('"banner"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { AssetSnippet, FigmaManifestRecord } from "./types";
|
||||
|
||||
function escapeAttr(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <fast> & 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/);
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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, "'")}'`
|
||||
: "";
|
||||
|
||||
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, "'")}'`
|
||||
: "";
|
||||
|
||||
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, "'")}'`);
|
||||
}
|
||||
|
||||
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, "'")}'`);
|
||||
}
|
||||
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}
|
||||
`;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user