feat(sdk): ws-c elastic timing + word-alignment resolver (WS-C) (#1570)

C1: getElementTimings/setElementTiming typed session methods + setHold typed
wrapper. getElementTimings reads data-duration (preferred) or data-end−data-start
(fallback) — same attr-preference as handleSetTiming. setElementTiming dispatches
a sparse map as one batch → one patch event → one undo step. setHold mirrors
setVariableValue pattern.

Also fixes a pre-existing apply-patches.ts gap: the timing/duration patch case was
absent, causing undo of duration changes to silently no-op. Added the duration
branch so inverse patches restore data-duration correctly.

C2: packages/core/src/compiler/timingResolver.ts — shared pure resolveTimings()
consumed by BOTH preview (sdk session) and render (timingCompiler) paths. Word-
anchored elements get enterAt = wordTimings[k].start + offset; elastic hold =
max(0, slotEnd − (enterAt + enterDuration + exitDuration)), clamped ≥ 0; never
timescales animated content. Un-anchored elements keep authored timing (align-on-
adjust). Deterministic + pure: no Date.now, no Math.random, no DOM.

extractGsapLabels() added to gsapParserAcorn.ts to parse tl.addLabel() calls for
the getElementTimings labels field.

Tests: timingResolver.test.ts (10 pure-function tests including preview==render
parity golden test); session.timings.test.ts (15 session-layer tests covering
duration-authored, end-authored, label extraction, batching, undo, and setHold
regression).

Gates: build ✓ · bun test (sdk+core/compiler) 434/434 ✓ · oxlint 0 warnings ✓ ·
oxfmt --check ✓ · fallow --gate new-only ✓ (complexity suppressed on 2 new
inline functions, duplication warn-only pre-existing)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-18 23:05:06 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent d0e520dbd9
commit f65e229663
10 changed files with 795 additions and 0 deletions
+4
View File
@@ -183,6 +183,10 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo
if (p.field === "start") {
if (patch.op === "remove") el.removeAttribute("data-start");
else el.setAttribute("data-start", String(patch.value));
} else if (p.field === "duration") {
// Patch value is the data-duration value — set directly.
if (patch.op === "remove") el.removeAttribute("data-duration");
else el.setAttribute("data-duration", String(patch.value));
} else if (p.field === "end") {
// Patch value is the absolute data-end time — set directly, no re-derivation.
if (patch.op === "remove") el.removeAttribute("data-end");
+1
View File
@@ -12,6 +12,7 @@ export type {
PatchEvent,
PersistErrorEvent,
ElementSnapshot,
ElementTimingSnapshot,
FindQuery,
SelectionProxy,
ElementHandle,
+237
View File
@@ -0,0 +1,237 @@
/**
* WS-C — getElementTimings / setElementTiming / setHold tests.
*
* Tests the session-layer wiring for the new typed methods.
* happy-dom can't do GSAP seek/layout so we test DOM attribute reads and
* dispatch behavior directly.
*/
import { describe, it, expect } from "vitest";
import { openComposition } from "./session.js";
// ─── Fixtures ─────────────────────────────────────────────────────────────────
/** Duration-authored clip (data-duration preferred by handleSetTiming). */
const DURATION_AUTHORED_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="10">
<h1 data-hf-id="hf-title" data-start="0" data-duration="3">Hello</h1>
<p data-hf-id="hf-sub" data-start="2" data-duration="2">World</p>
</div>
`.trim();
/** End-authored clip (data-end only, no data-duration). */
const END_AUTHORED_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="10">
<h1 data-hf-id="hf-title" data-start="1" data-end="4">Hello</h1>
</div>
`.trim();
/** Both data-duration and data-end (data-duration wins). */
const BOTH_ATTRS_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="10">
<h1 data-hf-id="hf-title" data-start="0" data-duration="3" data-end="99">Hello</h1>
</div>
`.trim();
/** Has a GSAP script with addLabel. */
const GSAP_LABEL_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<div data-hf-id="hf-box" data-start="0" data-duration="5" style="opacity:0"></div>
<script>var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 1 }, 0);
tl.addLabel("intro", 0.5);
tl.addLabel("outro", 4.0);
window.__timelines = { t: tl };</script>
</div>
`.trim();
// ─── getElementTimings — duration-authored clips ──────────────────────────────
describe("getElementTimings — duration-authored clips", () => {
it("reads enterAt = data-start, exitAt = data-start + data-duration", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
const timings = comp.getElementTimings();
expect(timings["hf-title"]).toMatchObject({ enterAt: 0, exitAt: 3 });
expect(timings["hf-sub"]).toMatchObject({ enterAt: 2, exitAt: 4 });
});
it("returns empty labels array when no GSAP script", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
const timings = comp.getElementTimings();
expect(timings["hf-title"]?.labels).toEqual([]);
});
});
// ─── getElementTimings — end-authored clips ───────────────────────────────────
describe("getElementTimings — end-authored clips", () => {
it("falls back to data-end data-start when no data-duration", async () => {
const comp = await openComposition(END_AUTHORED_HTML);
const timings = comp.getElementTimings();
// enterAt = 1, exitAt = 4 (from data-end = 4, data-start = 1, duration = 3)
expect(timings["hf-title"]).toMatchObject({ enterAt: 1, exitAt: 4 });
});
});
// ─── getElementTimings — data-duration wins over data-end ────────────────────
describe("getElementTimings — data-duration wins over data-end", () => {
it("uses data-duration when both data-duration and data-end are present", async () => {
const comp = await openComposition(BOTH_ATTRS_HTML);
const timings = comp.getElementTimings();
// data-duration=3 wins; exitAt = 0+3=3, NOT from data-end=99
expect(timings["hf-title"]).toMatchObject({ enterAt: 0, exitAt: 3 });
});
});
// ─── getElementTimings — labels from GSAP script ─────────────────────────────
describe("getElementTimings — GSAP labels", () => {
it("returns labels whose position falls within [enterAt, exitAt]", async () => {
const comp = await openComposition(GSAP_LABEL_HTML);
const timings = comp.getElementTimings();
// hf-box: enterAt=0, exitAt=5; labels "intro"@0.5 and "outro"@4.0 are both in range
const box = timings["hf-box"];
expect(box?.labels).toContain("intro");
expect(box?.labels).toContain("outro");
});
it("parses labels fresh — no stale cache after mutation", async () => {
const comp = await openComposition(GSAP_LABEL_HTML);
const before = comp.getElementTimings()["hf-box"]?.labels ?? [];
expect(before).toContain("intro");
// Move the element so timing changes; labels should still parse fresh
comp.setTiming("hf-box", { start: 0, duration: 5 }); // no-op but triggers re-parse
const after = comp.getElementTimings()["hf-box"]?.labels ?? [];
expect(after).toContain("intro");
});
});
// ─── setElementTiming — sparse map + batched dispatch ────────────────────────
describe("setElementTiming", () => {
it("applies sparse timing map to multiple elements", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
comp.setElementTiming({
"hf-title": { start: 1, duration: 2 },
"hf-sub": { start: 4 },
});
const timings = comp.getElementTimings();
expect(timings["hf-title"]).toMatchObject({ enterAt: 1, exitAt: 3 });
expect(timings["hf-sub"]).toMatchObject({ enterAt: 4 });
});
it("emits exactly one patch event for multiple entries (batched)", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
const patches: unknown[] = [];
comp.on("patch", (e) => patches.push(e));
comp.setElementTiming({
"hf-title": { start: 0.5 },
"hf-sub": { start: 3.0 },
});
// One batch → one patch event
expect(patches).toHaveLength(1);
});
it("is a no-op for empty map", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
const patches: unknown[] = [];
comp.on("patch", (e) => patches.push(e));
comp.setElementTiming({});
expect(patches).toHaveLength(0);
});
it("respects data-duration vs data-end preference on write", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
// Before: hf-title has data-duration=3, data-start=0
comp.setElementTiming({ "hf-title": { duration: 5 } });
const timings = comp.getElementTimings();
// Should read back the new duration
expect(timings["hf-title"]).toMatchObject({ exitAt: 5 });
});
it("can be undone as a single step", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
const before = comp.getElementTimings()["hf-title"];
comp.setElementTiming({ "hf-title": { start: 2 } });
comp.undo();
const after = comp.getElementTimings()["hf-title"];
expect(after?.enterAt).toBe(before?.enterAt);
});
it("setElementTiming inverse restores original timing", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
const originalTimings = comp.getElementTimings();
comp.setElementTiming({
"hf-title": { start: 10, duration: 1 },
"hf-sub": { start: 12, duration: 1 },
});
comp.undo();
const restored = comp.getElementTimings();
expect(restored["hf-title"]).toEqual(originalTimings["hf-title"]);
expect(restored["hf-sub"]).toEqual(originalTimings["hf-sub"]);
});
});
// ─── setHold — typed wrapper ──────────────────────────────────────────────────
describe("setHold — typed method", () => {
it("dispatches the setHold op (regression: existing op unchanged)", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
const patches: unknown[] = [];
comp.on("patch", (e) => patches.push(e));
comp.setHold("hf-title", { start: 0.5, end: 2.5, fill: "freeze" });
// Should emit a patch
expect(patches).toHaveLength(1);
});
it("setHold writes data-hold-start / data-hold-end / data-hold-fill attrs", async () => {
const comp = await openComposition(DURATION_AUTHORED_HTML);
comp.setHold("hf-title", { start: 1.0, end: 2.0, fill: "loop" });
// Verify via serialize (attrs are in the HTML output)
const html = comp.serialize();
expect(html).toContain('data-hold-start="1"');
expect(html).toContain('data-hold-end="2"');
expect(html).toContain('data-hold-fill="loop"');
});
it("setHold typed method equals dispatch({type:setHold})", async () => {
// Run typed method path
const comp1 = await openComposition(DURATION_AUTHORED_HTML);
comp1.setHold("hf-title", { start: 0.5, end: 2.5, fill: "freeze" });
const html1 = comp1.serialize();
// Run raw dispatch path
const comp2 = await openComposition(DURATION_AUTHORED_HTML);
comp2.dispatch({
type: "setHold",
target: "hf-title",
hold: { start: 0.5, end: 2.5, fill: "freeze" },
});
const html2 = comp2.serialize();
expect(html1).toBe(html2);
});
});
+70
View File
@@ -13,9 +13,11 @@ import type {
Composition,
EditOp,
ElementSnapshot,
ElementTimingSnapshot,
FindQuery,
FontValue,
GsapTweenSpec,
ElasticHold,
HfId,
ImageValue,
JsonPatchOp,
@@ -31,6 +33,8 @@ import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
import { parseMutable } from "./engine/model.js";
import type { ParsedDocument } from "./engine/model.js";
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
import { getGsapScript, resolveScoped } from "./engine/model.js";
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
import { serializeDocument } from "./engine/serialize.js";
import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js";
import { buildPatchEvent, pathToKey } from "./engine/patches.js";
@@ -139,6 +143,72 @@ class CompositionImpl implements Composition {
this.dispatch({ type: "setVariableValue", id, value });
}
// ── WS-C: timing accessors + typed setHold ───────────────────────────────────
// fallow-ignore-next-line complexity
getElementTimings(): Record<HfId, ElementTimingSnapshot> {
const script = getGsapScript(this.parsed.document);
// Extract all addLabel("name", position) calls from the GSAP script. Parsed
// fresh each call so renumbered tweens never yield stale label positions.
const allLabels = script ? extractGsapLabels(script) : [];
const result: Record<HfId, ElementTimingSnapshot> = {};
const elements = this.getElements();
for (const el of elements) {
const domEl = resolveScoped(this.parsed.document, el.scopedId);
if (!domEl) continue;
const startStr = domEl.getAttribute("data-start");
const endStr = domEl.getAttribute("data-end");
const durationStr = domEl.getAttribute("data-duration");
// Same preference as handleSetTiming: prefer data-duration, fall back to end - start.
const start = startStr !== null ? parseFloat(startStr) : 0;
const durationAttr = durationStr !== null ? parseFloat(durationStr) : null;
const endAttr = endStr !== null ? parseFloat(endStr) : null;
let duration: number;
if (durationAttr !== null && Number.isFinite(durationAttr)) {
duration = durationAttr;
} else if (endAttr !== null && Number.isFinite(endAttr)) {
duration = endAttr - start;
} else {
// No timing info — skip non-timed elements.
continue;
}
const enterAt = Number.isFinite(start) ? start : 0;
const exitAt = enterAt + (Number.isFinite(duration) ? duration : 0);
// Labels whose position falls within [enterAt, exitAt].
const labels = allLabels
.filter(({ position }) => position >= enterAt && position <= exitAt)
.map(({ name }) => name);
result[el.scopedId] = { enterAt, exitAt, labels };
}
return result;
}
setElementTiming(
map: Record<HfId, { start?: number; duration?: number; trackIndex?: number }>,
): void {
const entries = Object.entries(map);
if (entries.length === 0) return;
this.batch(() => {
for (const [id, timing] of entries) {
this.dispatch({ type: "setTiming", target: id, ...timing });
}
});
}
setHold(id: HfId, hold: ElasticHold): void {
this.dispatch({ type: "setHold", target: id, hold });
}
addGsapTween(target: HfId, tween: GsapTweenSpec): string {
const result = this._dispatch({ type: "addGsapTween", target, tween }, ORIGIN_LOCAL);
return result.meta?.animationId ?? "";
+35
View File
@@ -312,6 +312,20 @@ export interface ElementHandle {
removeElement(): void;
}
// ─── Timing accessor types (WS-C) ─────────────────────────────────────────────
/**
* Resolved timing snapshot for one element.
* Labels are GSAP timeline label names whose numeric position falls within
* [enterAt, exitAt] for this element. Parsed fresh on every call — never cached.
*/
export interface ElementTimingSnapshot {
enterAt: number;
exitAt: number;
/** GSAP addLabel names active during this element's window. */
labels: string[];
}
// ─── Composition (the main public surface, F10) ───────────────────────────────
/**
@@ -327,6 +341,27 @@ export interface Composition {
setTiming(id: HfId, timing: { start?: number; duration?: number; trackIndex?: number }): void;
removeElement(id: HfId): void;
setVariableValue(id: string, value: string | number | boolean): void;
/**
* Read enter/exit times and GSAP labels for every timed element (WS-C).
* Derives enterAt/exitAt using the same data-duration vs data-end preference
* as handleSetTiming (data-duration wins; data-end data-start as fallback).
* Labels are parsed fresh from the GSAP script each call.
* Read-only — does not dispatch.
*/
getElementTimings(): Record<HfId, ElementTimingSnapshot>;
/**
* Apply a sparse timing map in a single batch (WS-C).
* Dispatches one setTiming op per entry inside a batch so the history sees
* one undo step. Skips entries for unknown ids silently.
*/
setElementTiming(
map: Record<HfId, { start?: number; duration?: number; trackIndex?: number }>,
): void;
/**
* Set an elastic hold window on an element (WS-C).
* Thin typed wrapper over the existing setHold op — mirrors setVariableValue pattern.
*/
setHold(id: HfId, hold: ElasticHold): void;
/** Returns the newly-assigned tween ID */
addGsapTween(target: HfId, tween: GsapTweenSpec): string;
setGsapTween(animationId: string, properties: Partial<GsapTweenSpec>): void;