Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,12 @@
// this PR only teaches the server scan to prefer its reported PID, but that
// line shift makes fallow re-flag the inherited probe clones.
"packages/cli/src/server/portUtils.ts",
// iframe.test.ts: the remaining clone groups are pre-existing per-case arrange
// blocks in the selection and draft-loop suites (build an adapter, wire a spy,
// act). Appending the paint-query suite shifts their line numbers and re-flags
// them; each block states its own setup on purpose, which a shared fixture
// would hide.
"packages/sdk/src/adapters/iframe.test.ts",
// gsapParserAcorn.motionEval.test.ts: parallel arrange/act/assert cases for
// the staggered-collection honesty pass (.from reveal vs .to landing on the
// rest pose). Each asserts a distinct keyframe shape; collapsing the shared
Expand Down
59 changes: 59 additions & 0 deletions docs/sdk/guides/canvas-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,65 @@ iframeDoc.addEventListener("click", (e) => {

`resolveNearestHfElement` returns `null` when the walk exits the tree without finding a `[data-hf-id]` node, when the matching node carries `[data-hf-root]` (the root is transparent to selection), or when `isVisible` returns `false` for that node.

## Transparent compositions over other content

A composition authored as an overlay — a small graphic on an otherwise-empty 1080×1920 frame, layered over a video or an avatar — is still a rectangular DOM box covering every pixel of the frame. Without help it swallows every click, and whatever sits beneath it becomes unreachable.

`preview.paintsAt(x, y)` is the question you need answered: does the composition put ink here, or is the pointer over an empty gap? Toggle `pointer-events` on your wrapper from the answer, and let the browser deliver the event to the right target:

```typescript
const wrapper = document.querySelector<HTMLElement>("#composition-wrapper")!;

/**
* Host-page pointer coordinates → the iframe document's own client space, which is what
* `paintsAt` samples against. The iframe renders at the composition's native size and is
* CSS-scaled to fit, so the on-screen scale has to be divided out — skip this and you
* sample the wrong pixel, and pass-through toggles over the wrong regions.
*/
function toCompositionPoint(clientX: number, clientY: number) {
const rect = iframe.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const scaleX = rect.width / compositionNativeWidth;
const scaleY = rect.height / compositionNativeHeight;
if (!scaleX || !scaleY) return null;
return { x: (clientX - rect.left) / scaleX, y: (clientY - rect.top) / scaleY };
}

function updatePassThrough(clientX: number, clientY: number, altKey: boolean) {
const point = toCompositionPoint(clientX, clientY);
// Alt is the escape hatch for grabbing the composition itself in an empty region.
// `null` means the answer is not knowable yet — treat it as painted, so a
// composition that is still loading stays clickable.
const painted = point ? (preview.paintsAt?.(point.x, point.y) ?? null) : null;
const passThrough = !!point && !altKey && painted === false;
wrapper.style.pointerEvents = passThrough ? "none" : "";
}
```

<Warning>
Listen on the **host document**, not on the wrapper or the iframe. The first time this
sets `pointer-events: none` the wrapper stops receiving events, so a listener attached
there can never turn it back on — the pass-through state sticks.
</Warning>

Three things are easy to get wrong here:

<Steps>
<Step title="Decide before the press, not during it">
The browser picks an event's target before any handler runs, so flipping `pointer-events` inside `mousedown` cannot retarget the click already in flight. Sample the pointer position on `mousemove` and keep the decision current.
</Step>
<Step title="Re-evaluate on every frame, not only on movement">
Animated artwork moves under a stationary cursor. Anything that can change the answer — pointer movement, the Alt key, and the playhead — has to re-run the query from the last known position. Coalesce those triggers into one `requestAnimationFrame` query rather than answering each separately, and short-circuit before the query when the pointer is outside the composition's box: it is a walk over the document, so it does not belong on an ungated per-event path.
</Step>
<Step title="Treat null as painted">
`paintsAt` returns `null` when the document is not loaded or not readable. Erring toward "paints" costs a click that selects the composition; erring the other way makes the composition vanish from under the cursor.
</Step>
</Steps>

Pass `{ fullBleedFraction: 0.9 }` if your editor treats a layer covering nearly the whole frame as background rather than artwork — a common choice, since a full-bleed wrapper is usually scaffolding rather than something the user is pointing at.

Do not reimplement this with `elementsFromPoint`. That stack omits `pointer-events: none` nodes, and a decorative overlay carrying `pointer-events: none` still paints — a z-stack query would report no ink over visible artwork and pass the click through anyway. `paintsAt` walks element boxes geometrically for that reason.

## Draft loop: 60fps drag without model mutations

The draft loop keeps the model clean during a drag. The SDK is **not** in the 60fps path — you call `preview.applyDraft` on every `pointermove` and `preview.commitPreview` once on `pointerup`. The model sees exactly one `moveElement` op per drag, rather than hundreds.
Expand Down
54 changes: 52 additions & 2 deletions docs/sdk/reference/adapters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ Injectable preview surface adapter. Decouples the SDK from the host's rendering
```typescript
interface PreviewAdapter {
elementAtPoint(x: number, y: number, opts?: { atTime?: number }): ElementAtPointResult | null;
paintsAt?(x: number, y: number, opts?: PaintsAtOptions): boolean | null;
applyDraft(id: string, props: DraftProps): void;
commitPreview(): void;
cancelPreview(): void;
Expand All @@ -100,6 +101,20 @@ interface PreviewAdapter {
Synchronous hit-test at composition coordinates `(x, y)`. Returns the nearest `[data-hf-id]` element under the point, or `null` for a transparent hit (the composition root, an opacity-0 element, or nothing at all). Requires a same-origin iframe — cross-origin access throws a DOMException. The `atTime` option reflects GSAP state at the current playhead; seeking to a speculative time is not supported.
</ParamField>

<ParamField path="paintsAt" type="(x, y, opts?) => boolean | null">
Optional. Does the composition put ink at composition coordinates `(x, y)`? This is the question a host has to answer before a transparent composition layered over other content swallows a click: is the user pointing **at** artwork, or through an empty gap at whatever sits beneath? Geometry alone cannot tell — a composition is mostly full-bleed wrapper `<div>`s that cover every pixel of the frame without painting anything.

Returns `null` when the answer is not knowable (document not loaded, not readable, or the adapter has no surface). **Treat `null` as painted.** Erring toward "paints" costs a click that selects the composition; erring the other way makes the composition vanish from under the cursor.

Ink is a computed-style test — background colour, background image, visible border, the element's own text, or intrinsic media — with one exception: `<img>` (and the `<img>` inside a `<picture>`) routes through per-pixel alpha, so a transparent PNG paints only where its pixels do. A pixel-verified hit is never discounted by `fullBleedFraction`: box area is not ink area, so a full-frame transparent overlay stays clickable where it is actually opaque.

**Known over-counts** (report ink that isn't there, so a click selects the composition): a `background-image` that is itself mostly transparent reads as painting across its whole box; `<video>`, `<svg>` and `<canvas>` are unconditionally opaque; and an image whose pixels cannot be read — cross-origin without CORS, still loading, rotated, or above the sampler's size budget — falls back to opaque.

**Known under-counts** (miss ink that is there, so a click may pass through): `::before` / `::after` generated content, `box-shadow`, `outline` and `text-decoration` are not tested, and the first three paint outside the border box, so the element is not even a candidate. Content the SDK never stamped is invisible under the default `addressableOnly` — see below.

The walk is **geometric**, not `elementsFromPoint`-based, and is blind to `pointer-events` and `z-index` by design: a decorative overlay carrying `pointer-events: none` still paints, and a z-stack query would report no ink over visible artwork.
</ParamField>

<ParamField path="applyDraft" type="(id: string, props: DraftProps) => void">
Visually translates the preview element at 60fps during a drag: sets the element's CSS `translate` to its pre-drag value composed with the accumulated delta. Works on GSAP-animated elements (a `translate` set after GSAP's first parse composes with the animated transform). The **SDK is not called here** — this is a direct write to the preview surface by your pointer-move handler. Switching `id` mid-drag reverts the previous element's draft first.
</ParamField>
Expand Down Expand Up @@ -159,8 +174,27 @@ interface DraftProps {

`dx` and `dy` are the accumulated drag deltas in composition pixels. `width` and `height` are defined in the interface for forward compatibility but are not yet wired to any op.

### PaintsAtOptions

```typescript
interface PaintsAtOptions {
fullBleedFraction?: number;
addressableOnly?: boolean;
}
```

<ParamField path="fullBleedFraction" type="number" default="0">
A hit whose smallest painting box covers at least this fraction of the composition frame reads as background rather than ink. This is host policy, not a fact about the composition: an editor that treats "you clicked a layer covering the whole frame" as "you clicked the background" passes `0.9`, while a caller asking the literal ink question leaves it at `0`. Nested sub-compositions carry `data-composition-id` too, so the reference frame is the innermost composition root containing the point.
</ParamField>

<ParamField path="addressableOnly" type="boolean" default="true">
Consider only model-addressable elements (`[data-hf-id]`). Stamping happens once, on the document `openComposition` was given, so anything the runtime creates or fetches afterwards is invisible to the default walk: split-text word and character spans (splitting also empties the stamped parent's own text nodes, so the parent stops counting too), cloned nodes, and whole sub-composition scenes mounted from `data-composition-src`. Kinetic typography and registry-mounted lower-thirds — both canonical transparent-overlay content — therefore read as no-ink by default.

Set `false` to widen the walk to every element, which sees that content at the cost of a larger candidate set.
</ParamField>

<Note>
`ElementAtPointResult` and `DraftProps` are the structural shapes a `PreviewAdapter` produces and consumes. They are **not** re-exported from the `@hyperframes/sdk` barrel — you implement against these shapes rather than importing them.
`ElementAtPointResult` and `DraftProps` are the structural shapes a `PreviewAdapter` produces and consumes. They are **not** re-exported from the `@hyperframes/sdk` barrel — you implement against these shapes rather than importing them. `PaintsAtOptions` **is** re-exported, since callers pass it rather than implement it.
</Note>

---
Expand Down Expand Up @@ -258,7 +292,9 @@ import { createHeadlessAdapter } from "@hyperframes/sdk";
function createHeadlessAdapter(): PreviewAdapter;
```

Returns a no-op `PreviewAdapter` for headless use: agents, CI pipelines, and server-side rendering. All methods are stubs — `elementAtPoint` always returns `null`, `applyDraft` and `commitPreview` are no-ops, and the `"selection"` event never fires.
Returns a no-op `PreviewAdapter` for headless use: agents, CI pipelines, and server-side rendering. All methods are stubs — `elementAtPoint` and `paintsAt` always return `null`, `applyDraft` and `commitPreview` are no-ops, and the `"selection"` event never fires.

`paintsAt` returns `null` rather than `false` on purpose: an adapter with no surface cannot know whether the composition paints, and callers read `null` as painted. Answering `false` would tell a host that a composition it cannot see puts down no ink.

Pass this adapter when you open a composition for programmatic editing and do not need a live preview surface.

Expand Down Expand Up @@ -297,6 +333,18 @@ Returns a `PreviewAdapter` that bridges the SDK to a same-origin `<iframe>` cont

**Image-alpha hit-testing:** For `<img>` elements, the adapter samples the alpha channel of the pixel under the pointer using an `OffscreenCanvas`. Transparent pixels fall through to the element behind. Cross-origin images that taint the canvas are treated as opaque (safe fallback, logged once per src).

**Paint queries:** `paintsAt` answers whether the composition puts ink at a point — see the [`PreviewAdapter` interface](#previewadapter) above and the [transparent-overlay recipe](/sdk/guides/canvas-integration#transparent-compositions-over-other-content). The pieces it is built from are importable directly for hosts whose hit-test policy differs:

```typescript
import {
elementPaintsInk,
compositionPaintsAt,
imageAlphaOpaqueAt,
alphaIsOpaque,
mapPointToImagePixel,
} from "@hyperframes/sdk/adapters/iframe";
```

```typescript
import { openComposition, createIframePreviewAdapter } from "@hyperframes/sdk";

Expand Down Expand Up @@ -329,6 +377,8 @@ if (hit) {
| `createMemoryAdapter` | `@hyperframes/sdk` |
| `createHeadlessAdapter` | `@hyperframes/sdk` |
| `createIframePreviewAdapter`, `resolveNearestHfElement` | `@hyperframes/sdk` |
| `PaintsAtOptions` | `@hyperframes/sdk` (type only) |
| `elementPaintsInk`, `compositionPaintsAt`, `imageAlphaOpaqueAt`, `alphaIsOpaque`, `mapPointToImagePixel`, `INTRINSIC_PAINT_TAGS` | `@hyperframes/sdk/adapters/iframe` |
| `createFsAdapter`, `FsAdapterOptions` | `@hyperframes/sdk/adapters/fs` |

<CardGroup cols={2}>
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
"types": "./dist/adapters/headless.d.ts",
"environments": ["browser", "bun", "node"]
},
"./adapters/iframe": {
"source": "./src/adapters/iframe.ts",
"runtime": "./dist/adapters/iframe.js",
"types": "./dist/adapters/iframe.d.ts",
"environments": ["browser", "bun", "node"]
},
"./editing": {
"source": "./src/editing/affordances.ts",
"runtime": "./dist/editing/affordances.js",
Expand Down
9 changes: 9 additions & 0 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
"import": "./src/adapters/headless.ts",
"types": "./src/adapters/headless.ts"
},
"./adapters/iframe": {
"bun": "./src/adapters/iframe.ts",
"import": "./src/adapters/iframe.ts",
"types": "./src/adapters/iframe.ts"
},
"./editing": {
"bun": "./src/editing/affordances.ts",
"import": "./src/editing/affordances.ts",
Expand All @@ -59,6 +64,10 @@
"import": "./dist/adapters/headless.js",
"types": "./dist/adapters/headless.d.ts"
},
"./adapters/iframe": {
"import": "./dist/adapters/iframe.js",
"types": "./dist/adapters/iframe.d.ts"
},
"./editing": {
"import": "./dist/editing/affordances.js",
"types": "./dist/editing/affordances.d.ts"
Expand Down
14 changes: 14 additions & 0 deletions packages/sdk/src/adapters/headless.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, it, expect } from "vitest";

import { createHeadlessAdapter } from "./headless.js";

describe("createHeadlessAdapter", () => {
it("answers paintsAt with null, not false", () => {
// The two queries read the same value differently: for elementAtPoint null means
// "nothing there", for paintsAt it means "not knowable" and callers treat it as
// painted. Returning false here would tell a host that a composition it cannot
// see puts down no ink, which is the direction that makes content vanish.
expect(createHeadlessAdapter().paintsAt?.(10, 10)).toBeNull();
expect(createHeadlessAdapter().elementAtPoint(10, 10)).toBeNull();
});
});
11 changes: 10 additions & 1 deletion packages/sdk/src/adapters/headless.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
import type { PreviewAdapter, ElementAtPointResult, DraftProps, PaintsAtOptions } from "./types.js";
import type { Composition } from "../types.js";

/** Null PreviewAdapter for headless use (agents, CI, server-side rendering). */
Expand All @@ -7,6 +7,15 @@ class HeadlessPreviewAdapter implements PreviewAdapter {
return null;
}

/**
* null, not false — the same value means different things on the two queries. For
* elementAtPoint null is "nothing there"; for paintsAt it is "not knowable", which
* is the honest answer from an adapter with no surface. Callers read it as painted.
*/
paintsAt(_x: number, _y: number, _opts?: PaintsAtOptions): boolean | null {
return null;
}

applyDraft(_id: string, _props: DraftProps): void {}

commitPreview(): void {}
Expand Down
Loading