Files
hyperframes/packages/core/src/compiler/timingResolver.ts
T
Vance IngallsandClaude Opus 4.8 967bf9f9ed refactor(core): gate acorn GSAP writer behind cutover flag; keep recast default (WS-3F) (#1573)
* refactor(core): retire recast/babel, route all GSAP mutations to acorn (WS-E/3.F)

- Delete gsapParser.ts (2595-line recast-based parser/writer)
- Delete gsapParser.test.ts, gsapParser.stress.test.ts, gsapParser.test-helpers.ts
- Add gsapParserExports.ts: re-export umbrella for gsap-parser subpath
- Move SplitAnimationsOptions/SplitAnimationsResult to gsapSerialize.ts
- executeGsapMutation: async->sync, static acorn imports replace loadGsapParser()
- Fix 3 function name mismatches in files.ts switch cases
- generators/hyperframes.ts: imports from gsapSerialize (blocker resolved)
- gsapWriterAcorn.ts: SplitAnimationsOptions from gsapSerialize
- Parity tests: recast oracle removed; acorn-only regression (14 pass)
- Remove recast and @babel/parser from core/package.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): harden mutation handlers + widen variable API (code-review)

Self-contained review fixes for the SDK-hotspot stack (#1569–#1573). The
dispatch path (_dispatch → applyOp) never runs validateOp, so the new
WS-D/WS-3.C guards were advisory-only; re-enforce them in the handlers.

- addElement: null-guard the resolved parent (no more `as Element` masking a
  null → crash on unknown parent id); reject <script> and multi-root fragments
  via parseInsertableFragment instead of inserting raw markup / silently
  dropping extra roots.
- addWithKeyframes / replaceWithKeyframes: bail on empty keyframes (no
  degenerate `keyframes: {}` tween) and when the animationId resolves to
  nothing (no silent degrade-to-add leaving a duplicate tween).
- isObjectVariableValue: exclude arrays so an array override value can't be
  misclassified as a font/image object and written into the variable model.
- Composition.setVariableValue: widen the public interface signature to
  `… | FontValue | ImageValue` to match the impl + EditOp (B2 object-valued
  variables were unreachable via the typed API).
- mutate.gsap.test.ts: import addKeyframeToScript from gsap-writer-acorn —
  the gsap-parser subpath no longer re-exports write fns after recast retire,
  so the test threw at runtime (red suite).
- Dedup: export EXCLUDED_TAGS from hfIds.ts and drop the verbatim
  HF_EXCLUDED_TAGS copy in mutate.ts.

Adds guard regression tests. SDK 340/340, core hfIds 13/13, build green,
fallow --gate new-only clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sdk): variable-model dedup + undo/scoped-parent correctness; test honesty (code-review)

Second batch of review fixes for the SDK-hotspot stack.

- Variable model (#7, #13): extract readVariableDefault/writeVariableDefault into
  a shared engine/variableModel.ts used by both mutate.ts (forward) and
  apply-patches.ts (replay), so the model shape can't diverge. Add
  clearVariableDefault and make a `variable` remove patch DELETE the decl's
  `default` key — the exact inverse of a first-set on a default-less variable.
  Previously undo of such a set no-op'd and stranded the value.
- addElement scoped parent (#8): record the caller's id verbatim
  (scoped "hf-host/hf-leaf" path or composition id) as the patch parentId
  instead of the bare data-hf-id, so redo/replay re-resolves the SAME parent via
  resolveScoped rather than the canonical top-level dup (or document.body).
- resolveTimings honesty (#5): correct the header + test that claimed a live
  "preview == render" parity — neither path consumes the resolver yet (anchor
  inputs are Pacific/backend-deferred). It's a pure-function property, not a
  current guarantee.
- GSAP writer parity (#12): the recast oracle was deleted in WS-3.F, leaving the
  WS-3.C keyframe ops comparing acorn output to itself. Pin them as golden inline
  snapshots and drop the now-dead recast scaffolding (replaceWithKfRecast,
  removeAnimRecast alias). Remaining pre-WS-3.C parity blocks noted as follow-up.

Adds regression tests (undo of default-less variable; scoped-parent redo).
SDK 342/342, core timingResolver+parity green, build + fallow --gate new-only clean.

Not changed (need design / out of scope): #9 pre-#1569 persisted-override CSS
replay (moot for unreleased data; proper fix is render-time CSS derivation),
#11 replaceWithKeyframes stale positional id (mitigated by the missing-id no-op
guard + type doc; full fix needs non-positional ids).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sdk): replay CSS-prop derivation for legacy var overrides; stale-id selector guard (code-review)

Final review-fix batch — the two items deferred from the prior pass.

- #9 legacy variable-override CSS: applyOverrideSet now derives the `--{id}`
  CSS custom prop from any scalar `var.{id}` override on replay (and removes it
  for a null override). Sets written before the model/CSS split carried only
  `var.{id}`; without this, replaying them updated the JSON model but left
  `var(--{id})` bindings rendering the schema default. Replay-path only — the
  undo path (applyOne) is untouched, so #1569's separate-patch undo correctness
  is preserved. Object (font/image) values are never CSS, so they are skipped.
- #11 stale positional id: replaceWithKeyframes now requires the located
  animation to still target the caller's `targetSelector`. Position-derived ids
  re-point after structural edits; a stale id resolving to a DIFFERENT element's
  tween previously got silently replaced. It now bails (no-op) unless the id
  still points at the expected selector.

Adds regression tests (legacy var.{id}-only override restores CSS; object
override writes no CSS; stale-id-wrong-selector replace is a no-op).
SDK 345/345, build + fallow --gate new-only clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): gate acorn GSAP writer behind cutover flag; keep recast default (WS-3F)

Product decision pivot: acorn no longer replaces recast as the GSAP writer.
Recast remains the default server writer; acorn runs only when
STUDIO_SDK_CUTOVER_ENABLED=true (or =1) is set server-side — the same env
flag name as the client Vite var, so a single switch flips both sides.

Changes:
- Restore gsapParser.ts (recast writer) + test/stress/helper files deleted by 3F
- Restore @babel/parser + recast deps in packages/core/package.json
- Add isAcornGsapWriterEnabled() + loadGsapParser() to files.ts (lines 59-82)
- Split executeGsapMutation into async dispatcher + executeGsapMutationRecast
  (recast, async via loadGsapParser) + executeGsapMutationAcorn (acorn, sync)
- Dispatcher defaults to recast; acorn branch taken only when flag is on
- Restore gsapWriter.parity.test.ts, gsapWriterParity.acorn.test.ts, and
  gsapWriterParity.corpus.test.ts to true recast-vs-acorn differential suites
  (not acorn-vs-itself)
- Exempt gsapParser.ts in .fallowrc.jsonc health.ignore + ignoreExports
  (pre-existing complexity + barrel re-exports consumed outside diff scope)
- Add fallow-ignore-file code-duplication to files.ts (intentional parallel
  switch bodies for two writers)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 00:22:14 -07:00

158 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared pure timing resolver — WS-C.
*
* resolveTimings() is the single intended implementation of word-anchored
* elastic timing, designed to be the one code path that BOTH the preview
* (session layer in @hyperframes/sdk) and render (timingCompiler.ts +
* htmlBundler) sides call so they cannot drift apart.
*
* NOT YET WIRED: neither path consumes it yet — the anchor-producing inputs
* (TTS word timings) arrive on the Pacific/backend side, which is deferred.
* Until a real caller lands, the "preview == render" parity below is a property
* of the resolver (one pure function) rather than a guarantee the two live
* paths currently share. Wire it into timingCompiler and session before
* relying on it for parity.
*
* 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;
}