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
@@ -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;
}