Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion docs/sdk/guides/canvas-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ iframeDoc.addEventListener("click", (e) => {

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.

`applyDraft` writes CSS custom properties (`--hf-studio-dx`, `--hf-studio-dy`) directly onto the target element inside the iframe. The composition's CSS uses these vars to translate the element visually. Nothing in the SDK model changes.
`applyDraft` sets the element's CSS `translate` directly inside the iframe — the pre-drag value composed with the accumulated delta — so the drag is visible in any composition without composition-side CSS, and works on GSAP-animated elements (a `translate` set after GSAP's first parse composes with the animated transform instead of being overwritten). Nothing in the SDK model changes. `cancelPreview` restores the pre-drag translate; `commitPreview` derives one `moveElement` op and mirrors the committed position onto the live element.

```typescript
let dragging = false;
Expand Down
6 changes: 3 additions & 3 deletions docs/sdk/reference/adapters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,15 @@ interface PreviewAdapter {
</ParamField>

<ParamField path="applyDraft" type="(id: string, props: DraftProps) => void">
Applies draft CSS markers to the preview element at 60fps during a drag. Writes CSS custom properties (`--hf-studio-dx`, `--hf-studio-dy`) onto the element so the composition's CSS can visually translate it without touching the model. The **SDK is not called here** — this is a direct write to the preview surface by your pointer-move handler.
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 markers, derives a `moveElement` op from them, dispatches it into the SDK, emits a patch event, and clears the markers. This is the only moment the SDK becomes aware of a drag.
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">
Reverts the draft CSS markers without dispatching any op. The model is never changed. Call this on `Escape` keydown or when a drag is aborted.
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">
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@
"import": "./src/runtime/clipTree.ts",
"types": "./src/runtime/clipTree.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/lottie-readiness": {
"bun": "./src/lottieReadiness.ts",
"node": "./dist/lottieReadiness.js",
Expand Down Expand Up @@ -259,6 +265,10 @@
"import": "./dist/runtime/clipTree.js",
"types": "./dist/runtime/clipTree.d.ts"
},
"./runtime/position-edits": {
"import": "./dist/runtime/positionEdits.js",
"types": "./dist/runtime/positionEdits.d.ts"
},
"./runtime/lottie-readiness": {
"import": "./dist/lottieReadiness.js",
"types": "./dist/lottieReadiness.d.ts"
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/runtime/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export type RuntimeAnalyticsEvent =
| "composition_paused"
| "composition_seeked"
| "composition_ended"
| "element_picked";
| "element_picked"
| "position_edit_fold_skipped";

export type RuntimeAnalyticsProperties = Record<string, string | number | boolean | null>;

Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { createRuntimeStartTimeResolver } from "./startResolver";
import { createClipTree } from "./clipTree";
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
import { applyCaptionOverrides } from "./captionOverrides";
import { applyPositionEdits } from "./positionEdits";
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
Expand Down Expand Up @@ -72,6 +73,12 @@ function resolveExportRenderFps(): ExportRenderFpsResolution {

export function initSandboxRuntimeModular(): void {
const state = createRuntimeState();
// SDK moveElement edits must render even when no usable GSAP timeline ever
// binds (CSS/WAAPI-animated or fully static compositions) — apply at init.
// This runs at DOMContentLoaded, after inline composition scripts have
// parsed their tweens, so GSAP (when present) won't fold the translate.
// Re-applied on every timeline bind for the rebind/soft-reload paths.
applyPositionEdits(document);
const exportRenderFps = resolveExportRenderFps();
state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps;
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) {
Expand Down Expand Up @@ -1189,6 +1196,12 @@ export function initSandboxRuntimeModular(): void {
// during initial rebind (timing race on first load / soft reload).
const applyFn = (window as Record<string, unknown>).__hfStudioManualEditsApply;
if (typeof applyFn === "function") applyFn();

// SDK moveElement edits (data-hf-edit-base-x/y markers) render as a
// CSS translate delta. Must run after the timeline is bound so GSAP has
// already parsed the elements — a translate present at first parse gets
// folded into the cached transform and lost per-axis on seek.
applyPositionEdits(document);
}
if (resolution.diagnostics) {
postRuntimeMessage({
Expand Down
150 changes: 150 additions & 0 deletions packages/core/src/runtime/positionEdits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import {
EDIT_BASE_X_ATTR,
EDIT_BASE_Y_ATTR,
EDIT_ORIGINAL_TRANSLATE_ATTR,
applyPositionEditToElement,
applyPositionEdits,
composeTranslate,
} from "./positionEdits";

function makeElement(attrs: Record<string, string>, style = ""): HTMLElement {
const el = document.createElement("div");
for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
if (style) el.setAttribute("style", style);
document.body.appendChild(el);
return el;
}

describe("composeTranslate", () => {
it("returns the delta alone when there is no original", () => {
expect(composeTranslate("", "10px", "20px")).toBe("10px 20px");
expect(composeTranslate("none", "10px", "20px")).toBe("10px 20px");
});

it("adds px values numerically", () => {
expect(composeTranslate("5px 6px", "10px", "20px")).toBe("15px 26px");
expect(composeTranslate("-5.5px 6px", "10px", "-20px")).toBe("4.5px -14px");
});

it("treats a single-part original as x-only", () => {
expect(composeTranslate("5px", "10px", "20px")).toBe("15px 20px");
});

it("falls back to calc() for non-px units and preserves z", () => {
expect(composeTranslate("10% 6px", "10px", "20px")).toBe("calc(10% + 10px) 26px");
expect(composeTranslate("1px 2px 3px", "10px", "20px")).toBe("11px 22px 3px");
});
});

describe("applyPositionEdits", () => {
it("ignores unmarked elements", () => {
const el = makeElement({ "data-x": "100", "data-y": "50" });
expect(applyPositionEdits(document)).toBe(0);
expect(el.style.getPropertyValue("translate")).toBe("");
el.remove();
});

it("applies the delta between data-x/y and the captured baseline", () => {
const el = makeElement({
"data-x": "150",
"data-y": "-30",
[EDIT_BASE_X_ATTR]: "100",
[EDIT_BASE_Y_ATTR]: "20",
});
expect(applyPositionEdits(document)).toBe(1);
expect(el.style.getPropertyValue("translate")).toBe("50px -50px");
expect(el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR)).toBe("");
el.remove();
});

it("treats missing data-x/y or baseline attributes as 0", () => {
const el = makeElement({ "data-x": "40", [EDIT_BASE_X_ATTR]: "0" });
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("40px 0px");
el.remove();
});

it("composes with a pre-existing inline translate and stays idempotent", () => {
const el = makeElement(
{ "data-x": "10", "data-y": "20", [EDIT_BASE_X_ATTR]: "0", [EDIT_BASE_Y_ATTR]: "0" },
"translate: 5px 6px",
);
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("15px 26px");
expect(el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR)).toBe("5px 6px");
// Second application must not compound.
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("15px 26px");
el.remove();
});

it("recomputes from the same baseline after data-x changes", () => {
const el = makeElement({
"data-x": "10",
"data-y": "0",
[EDIT_BASE_X_ATTR]: "0",
[EDIT_BASE_Y_ATTR]: "0",
});
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("10px 0px");
el.setAttribute("data-x", "70");
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("70px 0px");
el.remove();
});

it("never re-captures the original translate once set", () => {
const el = makeElement({
"data-x": "10",
"data-y": "0",
[EDIT_BASE_X_ATTR]: "0",
[EDIT_BASE_Y_ATTR]: "0",
[EDIT_ORIGINAL_TRANSLATE_ATTR]: "3px 4px",
});
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("13px 4px");
el.remove();
});

it("counts and applies multiple marked elements", () => {
const a = makeElement({ "data-x": "1", [EDIT_BASE_X_ATTR]: "0" });
const b = makeElement({ "data-y": "2", [EDIT_BASE_Y_ATTR]: "0" });
expect(applyPositionEdits(document)).toBe(2);
a.remove();
b.remove();
});

it("skips re-apply when the written translate was consumed externally (GSAP fold)", () => {
const el = makeElement({
"data-x": "10",
"data-y": "20",
[EDIT_BASE_X_ATTR]: "0",
[EDIT_BASE_Y_ATTR]: "0",
});
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("10px 20px");
// GSAP folding the translate into its cached transform writes "none".
el.style.setProperty("translate", "none");
applyPositionEdits(document);
// Re-setting would double the offset on non-animated axes — must skip.
expect(el.style.getPropertyValue("translate")).toBe("none");
el.remove();
});

it("force re-applies over a clobbered translate (editor commit path)", () => {
const el = makeElement({
"data-x": "10",
"data-y": "20",
[EDIT_BASE_X_ATTR]: "0",
[EDIT_BASE_Y_ATTR]: "0",
});
applyPositionEditToElement(el);
// A drag draft overwrites the translate; the commit must recompute.
el.style.setProperty("translate", "999px 999px");
el.setAttribute("data-x", "30");
applyPositionEditToElement(el, { force: true });
expect(el.style.getPropertyValue("translate")).toBe("30px 20px");
el.remove();
});
});
Loading
Loading