mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 18:01:20 +08:00
7a91b93dd6
Miguel's three P2s and Rames' one finding on #2974, all verified in source before changing anything. **`render --json` is not a progress stream.** It prints exactly one `batch-complete` document at the end (`batchRender.ts:408-418`), asserted as a single `console.log` in `batchRender.test.ts`. Described as a final result now. **The iframe drag example never captured the pointer.** `event.target` comes from `iframe.contentDocument`, so `instanceof Element` against this window's constructor is always false for a cross-realm node and `setPointerCapture()` never ran — a pointer leaving the frame then loses `pointerup` and drag state sticks. Structural feature detection instead, with the reason in a comment so it does not get "simplified" back. **The preview adapter example did not compile under strict TypeScript.** `comp` was captured by the callback before definite assignment (TS2454). Optional, with `comp?.dispatch(op)`. **`ORIGIN_APPLY_PATCHES` was imported in a fence that did not use it and used in fences that did not import it.** Imports do not cross fences, so both examples were wrong in opposite directions. Rames found the pair in `open-composition.mdx`; the same shape is in `composition.mdx:630`, which he did not name. All three fences are self-contained now. **And `types.mdx` claimed coverage it does not have.** It promised "every type exported from `@hyperframes/sdk`" while omitting 13 of 42. Eleven are documented on sibling pages, so the sentence now points at those instead of overclaiming. The two with no home anywhere — `CompositionVariableType` and `VariableUsageScan`, both re-exported from the barrel — have entries. The second is worth having written down: `scanIncomplete` means `usedIds` is a lower bound, so an id missing from it is unknown rather than unused.
342 lines
16 KiB
Plaintext
342 lines
16 KiB
Plaintext
---
|
|
title: "Adapters"
|
|
description: "Persistence and preview adapter interfaces, contracts, and the built-in factory functions."
|
|
---
|
|
|
|
The SDK decouples editing sessions from storage and preview surfaces through two injectable interfaces: `PersistAdapter` and `PreviewAdapter`. Both ship with concrete factory functions you pass to `openComposition()`. You can also implement either interface directly for custom storage backends (S3, IndexedDB, HTTP) or custom preview surfaces.
|
|
|
|
## PersistAdapter
|
|
|
|
```typescript
|
|
import type { PersistAdapter } from "@hyperframes/sdk";
|
|
```
|
|
|
|
Injectable storage adapter. Decouples the SDK from the underlying persistence mechanism so the same session code runs in tests (memory), local dev (filesystem), and production (cloud storage).
|
|
|
|
### Interface
|
|
|
|
```typescript
|
|
interface PersistAdapter {
|
|
read(path: string): Promise<string | undefined>;
|
|
write(path: string, content: string): Promise<void>;
|
|
flush(): Promise<void>;
|
|
listVersions(path: string): Promise<PersistVersionEntry[]>;
|
|
loadFrom(path: string, versionKey: string): Promise<string | undefined>;
|
|
on(event: "persist:error", handler: (event: PersistErrorEvent) => void): () => void;
|
|
}
|
|
```
|
|
|
|
<ParamField path="read" type="(path: string) => Promise<string | undefined>">
|
|
Returns the stored content for `path`, or `undefined` for a path that has never been written. Never throws for a missing path.
|
|
</ParamField>
|
|
|
|
<ParamField path="write" type="(path: string, content: string) => Promise<void>">
|
|
Persists `content` at `path`. Idempotent — a second call with the same path overwrites the prior value. Write failures must not propagate as thrown exceptions; fire `persist:error` instead.
|
|
</ParamField>
|
|
|
|
<ParamField path="flush" type="() => Promise<void>">
|
|
Forces any queued or in-flight writes to commit before resolving. Call before process exit or navigation to prevent data loss.
|
|
</ParamField>
|
|
|
|
<ParamField path="listVersions" type="(path: string) => Promise<PersistVersionEntry[]>">
|
|
Returns the version history for `path` ordered newest-first. Returns an empty array when no versions exist. See `PersistVersionEntry` below.
|
|
</ParamField>
|
|
|
|
<ParamField path="loadFrom" type="(path: string, versionKey: string) => Promise<string | undefined>">
|
|
Returns the HTML content for a specific version identified by `versionKey`. Returns `undefined` when the key does not exist.
|
|
</ParamField>
|
|
|
|
<ParamField path="on" type='(event: "persist:error", handler) => () => void'>
|
|
Subscribes to write failures. Returns an unsubscribe function. Adapters must emit this event — not throw — when a write fails, so the session continues running even when storage is temporarily unavailable.
|
|
</ParamField>
|
|
|
|
### Contract summary
|
|
|
|
- `read()` returns `undefined` for a path that has never been written — never throws ENOENT or a 404 equivalent.
|
|
- `write()` is idempotent; a second write to the same path replaces the stored content.
|
|
- `flush()` resolves when any pending writes are committed to durable storage.
|
|
- `listVersions()` returns entries newest-first; `loadFrom()` uses the keys from those entries.
|
|
- Write errors are emitted via `on('persist:error')`, never thrown — the session keeps running.
|
|
|
|
### PersistVersionEntry
|
|
|
|
```typescript
|
|
interface PersistVersionEntry {
|
|
/** Opaque key identifying this version (adapter-defined format). */
|
|
key: string;
|
|
/** Full HTML content — may be omitted by adapters that load content lazily via loadFrom(). */
|
|
content?: string;
|
|
timestamp?: number;
|
|
}
|
|
```
|
|
|
|
The `key` is adapter-defined and opaque to callers — pass it directly to `loadFrom()`. The filesystem adapter encodes milliseconds and a counter into the key; the memory adapter uses an incrementing `"v1"`, `"v2"` … scheme.
|
|
|
|
---
|
|
|
|
## PreviewAdapter
|
|
|
|
```typescript
|
|
import type { PreviewAdapter } from "@hyperframes/sdk";
|
|
```
|
|
|
|
Injectable preview surface adapter. Decouples the SDK from the host's rendering layer. The SDK is **not** in the 60fps draft loop: your pointer-move handler calls `applyDraft()` directly on the adapter at 60fps, and the SDK only gets involved once per gesture when `commitPreview()` fires to derive and dispatch the resulting op.
|
|
|
|
### Interface
|
|
|
|
```typescript
|
|
interface PreviewAdapter {
|
|
elementAtPoint(x: number, y: number, opts?: { atTime?: number }): ElementAtPointResult | null;
|
|
applyDraft(id: string, props: DraftProps): void;
|
|
commitPreview(): void;
|
|
cancelPreview(): void;
|
|
select(ids: string[], opts?: { additive?: boolean }): void;
|
|
on(event: "selection", handler: (ids: string[]) => void): () => void;
|
|
attachSync(comp: Composition): () => void;
|
|
}
|
|
```
|
|
|
|
<ParamField path="elementAtPoint" type="(x, y, opts?) => ElementAtPointResult | null">
|
|
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="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>
|
|
|
|
<ParamField path="commitPreview" type="() => void">
|
|
Called once on pointer-up. Reads the accumulated draft delta, derives a `moveElement` op from it, dispatches it into the SDK, emits a patch event, and mirrors the committed position onto the live element (so it holds without a reload). This is the only moment the SDK becomes aware of a drag. If dispatch throws, the draft translate is reverted and the error propagates.
|
|
</ParamField>
|
|
|
|
<ParamField path="cancelPreview" type="() => void">
|
|
Restores the element's pre-drag `translate` without dispatching any op. The model is never changed. Call this on `Escape` keydown or when a drag is aborted.
|
|
</ParamField>
|
|
|
|
<ParamField path="select" type="(ids: string[], opts?: { additive?: boolean }) => void">
|
|
Sets the preview selection and fires `selectionchange` on the session. Pass `{ additive: true }` to merge `ids` into the current selection rather than replacing it.
|
|
</ParamField>
|
|
|
|
<ParamField path="on" type='(event: "selection", handler: (ids: string[]) => void) => () => void'>
|
|
Fired when the preview host changes the selection (for example, the user clicks an element). Returns an unsubscribe function. In the current release, callers listen to the session's own `selectionchange` event instead — this hook is wired in a future stage.
|
|
</ParamField>
|
|
|
|
<ParamField path="attachSync" type="(comp: Composition) => () => void">
|
|
Mirrors a composition's edits onto the adapter's own live document: an immediate full sync of the composition's current overrides, then a subscription that replays every future `patch` event — including undo/redo, since both fire through the same event with forward or inverse patches. Calling `attachSync` again while already attached detaches the previous subscription first. The full-override sync also re-runs on every iframe `load`, so a `srcdoc` navigation that races the attach (or drops patches committed during the load window) converges once the new document arrives. Returns an unsubscribe function.
|
|
|
|
Script-tag patches (`/script/gsap` and any future `/script/*` path) are never mirrored — rewriting a live `<script>` tag's content doesn't re-execute it, and re-running GSAP setup from scratch would conflict with running timeline state. Every other patch kind (style, text, attribute, timing, hold, element add/remove, stylesheet, variable value, variable declaration) mirrors as-is.
|
|
|
|
```typescript
|
|
const adapter = createIframePreviewAdapter(iframe, dispatch);
|
|
const comp = await openComposition(html, { preview: adapter });
|
|
const detach = adapter.attachSync(comp);
|
|
|
|
// later, if the host tears down the preview:
|
|
detach();
|
|
```
|
|
</ParamField>
|
|
|
|
### ElementAtPointResult
|
|
|
|
```typescript
|
|
interface ElementAtPointResult {
|
|
id: string;
|
|
tag: string;
|
|
}
|
|
```
|
|
|
|
The `id` is the element's `data-hf-id` value; `tag` is its lowercase tag name (e.g. `"div"`, `"img"`).
|
|
|
|
### DraftProps
|
|
|
|
```typescript
|
|
interface DraftProps {
|
|
dx?: number;
|
|
dy?: number;
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
```
|
|
|
|
`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.
|
|
|
|
<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.
|
|
</Note>
|
|
|
|
---
|
|
|
|
## Factory Functions
|
|
|
|
### createMemoryAdapter
|
|
|
|
```typescript
|
|
import { createMemoryAdapter } from "@hyperframes/sdk";
|
|
|
|
function createMemoryAdapter(): PersistAdapter & { injectFault(message: string): void };
|
|
```
|
|
|
|
Returns a `PersistAdapter` backed by an in-process `Map`. Writes are synchronous; `flush()` is a no-op. Versions are keyed `"v1"`, `"v2"` … and stored in memory with full content.
|
|
|
|
The returned value also exposes `injectFault(message)` — a test helper that causes the **next** `write()` call to fire a `persist:error` event with `message` instead of committing. Use this in unit tests to verify your error-handling code path.
|
|
|
|
```typescript
|
|
const persist = createMemoryAdapter();
|
|
|
|
const comp = await openComposition(html, { persist });
|
|
comp.setText("hf-title", "Hello");
|
|
await comp.flush();
|
|
|
|
const saved = await persist.read("composition.html");
|
|
```
|
|
|
|
<Note>
|
|
`createMemoryAdapter()` is best suited for tests, demos, and ephemeral in-process sessions. For local development, use `createFsAdapter()` so edits survive restarts.
|
|
</Note>
|
|
|
|
---
|
|
|
|
### createFsAdapter
|
|
|
|
```typescript
|
|
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
|
|
|
|
function createFsAdapter(opts: FsAdapterOptions): PersistAdapter;
|
|
```
|
|
|
|
**Node.js only.** Returns a `PersistAdapter` that reads and writes files under a root directory. Import from the `@hyperframes/sdk/adapters/fs` subpath — this module uses Node `fs/promises` and is excluded from the browser-safe main bundle.
|
|
|
|
#### FsAdapterOptions
|
|
|
|
```typescript
|
|
interface FsAdapterOptions {
|
|
/** Root directory for composition files. */
|
|
root: string;
|
|
/** Max versions to keep per file. Default: 20. */
|
|
maxVersions?: number;
|
|
}
|
|
```
|
|
|
|
<ParamField path="root" type="string" required>
|
|
Absolute or relative path to the directory where composition files are written. Created with `mkdir -p` on first write.
|
|
</ParamField>
|
|
|
|
<ParamField path="maxVersions" type="number">
|
|
Maximum number of historical versions retained per file. Oldest versions are pruned automatically when the limit is exceeded. Defaults to `20`.
|
|
</ParamField>
|
|
|
|
The adapter writes the current composition at `{root}/{path}` and stores version snapshots in `{root}/.hf-versions/{path}/`. Version keys encode `Date.now()` and a monotonic counter (`"1750000000000-0001"`), so `listVersions()` returns them newest-first by lexicographic descending sort.
|
|
|
|
```typescript
|
|
import { openComposition } from "@hyperframes/sdk";
|
|
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
|
|
|
|
const comp = await openComposition(html, {
|
|
persist: createFsAdapter({ root: "./project", maxVersions: 50 }),
|
|
persistPath: "index.html",
|
|
});
|
|
|
|
comp.setText("hf-title", "Saved");
|
|
await comp.flush();
|
|
|
|
// List saved versions
|
|
const adapter = createFsAdapter({ root: "./project" });
|
|
const versions = await adapter.listVersions("index.html");
|
|
const previous = await adapter.loadFrom("index.html", versions[1].key);
|
|
```
|
|
|
|
<Warning>
|
|
`createFsAdapter` uses Node.js `fs/promises`. Do not import it in browser or edge environments — import from `@hyperframes/sdk/adapters/fs` (the subpath) so bundlers can tree-shake it.
|
|
</Warning>
|
|
|
|
---
|
|
|
|
### createHeadlessAdapter
|
|
|
|
```typescript
|
|
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.
|
|
|
|
Pass this adapter when you open a composition for programmatic editing and do not need a live preview surface.
|
|
|
|
```typescript
|
|
import { openComposition, createHeadlessAdapter } from "@hyperframes/sdk";
|
|
|
|
const comp = await openComposition(html, {
|
|
preview: createHeadlessAdapter(),
|
|
});
|
|
```
|
|
|
|
<Note>
|
|
`openComposition` does not create a preview adapter automatically. Omit `preview` when no preview
|
|
surface is needed, or pass `createHeadlessAdapter()` when an explicit no-op adapter makes shared
|
|
code clearer.
|
|
</Note>
|
|
|
|
---
|
|
|
|
### createIframePreviewAdapter
|
|
|
|
```typescript
|
|
import { createIframePreviewAdapter } from "@hyperframes/sdk";
|
|
|
|
function createIframePreviewAdapter(
|
|
iframe: HTMLIFrameElement,
|
|
dispatch?: (op: EditOp) => void,
|
|
): PreviewAdapter;
|
|
```
|
|
|
|
Returns a `PreviewAdapter` that bridges the SDK to a same-origin `<iframe>` containing the composition. Provides real hit-testing via `elementsFromPoint` (z-stack aware), draft drag support, and selection management.
|
|
|
|
**Requirements:**
|
|
- The iframe must be same-origin (e.g. a `srcdoc` or `blob:` URL). Cross-origin access to `contentDocument` throws a `DOMException`.
|
|
- Pass your session's `dispatch` callback to enable `commitPreview()` — without it, pointer-up is a no-op on the model.
|
|
|
|
**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).
|
|
|
|
```typescript
|
|
import { openComposition, createIframePreviewAdapter } from "@hyperframes/sdk";
|
|
|
|
const iframe = document.querySelector<HTMLIFrameElement>("#preview-frame")!;
|
|
// Optional, and dispatched with `?.` — the callback cannot fire before
|
|
// openComposition() resolves, but strict TypeScript cannot prove that and
|
|
// rejects a definite `let` captured before assignment (TS2454).
|
|
let comp: Awaited<ReturnType<typeof openComposition>> | undefined;
|
|
const preview = createIframePreviewAdapter(iframe, (op) => comp?.dispatch(op));
|
|
comp = await openComposition(html, { preview });
|
|
|
|
// Hit-test at pointer position
|
|
const hit = preview.elementAtPoint(pointerX, pointerY);
|
|
if (hit) {
|
|
preview.select([hit.id]);
|
|
|
|
// Drag: call applyDraft at 60fps, commitPreview on pointer-up
|
|
preview.applyDraft(hit.id, { dx: 12, dy: -5 });
|
|
preview.commitPreview();
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Export Map
|
|
|
|
| Symbol | Imported from |
|
|
|--------|---------------|
|
|
| `PersistAdapter`, `PreviewAdapter`, `PersistVersionEntry` | `@hyperframes/sdk` (types only) |
|
|
| `createMemoryAdapter` | `@hyperframes/sdk` |
|
|
| `createHeadlessAdapter` | `@hyperframes/sdk` |
|
|
| `createIframePreviewAdapter`, `resolveNearestHfElement` | `@hyperframes/sdk` |
|
|
| `createFsAdapter`, `FsAdapterOptions` | `@hyperframes/sdk/adapters/fs` |
|
|
|
|
<CardGroup cols={2}>
|
|
<Card title="Persistence Guide" icon="floppy-disk" href="/sdk/guides/persistence">
|
|
How to wire adapters into openComposition, handle errors, and restore versions.
|
|
</Card>
|
|
<Card title="Canvas Integration" icon="browser" href="/sdk/guides/canvas-integration">
|
|
Building a visual editor canvas with the iframe preview adapter and hit-testing.
|
|
</Card>
|
|
</CardGroup>
|