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
+12
View File
@@ -1,3 +1,15 @@
// 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,
@@ -0,0 +1,214 @@
/**
* 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.
describe("preview == render parity golden test", () => {
it("resolver output is identical for preview and render call sites (shared single impl)", () => {
// 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 });
// They must be identical — this is the "preview == render" guarantee.
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,153 @@
/**
* Shared pure timing resolver — WS-C.
*
* resolveTimings() is the SINGLE implementation of word-anchored elastic timing.
* It is consumed by both:
* 1. The preview path (session layer in @hyperframes/sdk)
* 2. The render path (timingCompiler.ts + htmlBundler in @hyperframes/core)
*
* "preview == render" guarantee: there is exactly one code path for anchor
* resolution so both environments produce identical enter/exit times.
*
* 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;
}
+11
View File
@@ -108,6 +108,17 @@ export type {
CompilationResult,
} from "./compiler/timingCompiler";
// Timing resolver — shared pure resolver for word-anchored elastic timing (WS-C).
export type {
WordTiming,
ElementAnchor,
AuthoredTiming,
ResolvedTiming,
ResolveTimingsInput,
ResolveTimingsResult,
} from "./compiler/timingResolver";
export { resolveTimings } from "./compiler/timingResolver";
export {
compileTimingAttrs,
injectDurations,
@@ -1144,3 +1144,61 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap {
return { animations: [], timelineVar: "tl", preamble: "", postamble: "" };
}
}
// ── Label extraction (WS-C) ──────────────────────────────────────────────────
export interface GsapLabelEntry {
name: string;
position: number;
}
/**
* Extract all `tl.addLabel("name", position)` calls from a GSAP script.
*
* Returns labels in source order. Position must be a numeric literal; labels
* with non-numeric positions (e.g. label-relative offsets) are skipped.
*
* Pure — no side effects, no DOM, no Date.now.
*/
export function extractGsapLabels(script: string): GsapLabelEntry[] {
try {
const ast = acorn.parse(script, {
ecmaVersion: "latest",
sourceType: "script",
locations: true,
});
const scope = collectScopeBindings(ast);
const detection = findTimelineVar(ast, scope);
const timelineVar = detection.timelineVar ?? "tl";
const labels: GsapLabelEntry[] = [];
acornWalk.simple(ast, {
// fallow-ignore-next-line complexity
ExpressionStatement(node: any) {
const expr = node.expression;
if (!expr || expr.type !== "CallExpression") return;
const callee = expr.callee;
// Match tl.addLabel(...)
if (
callee?.type !== "MemberExpression" ||
callee.object?.name !== timelineVar ||
callee.property?.name !== "addLabel"
)
return;
const args = expr.arguments ?? [];
const nameNode = args[0];
const posNode = args[1];
if (nameNode?.type !== "Literal" || typeof nameNode.value !== "string") return;
if (!posNode) return;
const pos = resolveNode(posNode, scope);
if (typeof pos !== "number" || !Number.isFinite(pos)) return;
labels.push({ name: nameNode.value, position: pos });
},
});
return labels;
} catch {
return [];
}
}