Commit Graph
289 Commits
Author SHA1 Message Date
Miguel Ángel 45d4a71ed0 feat(core): GSAP-aware split engine for timeline clip splitting (#1330)
* refactor(studio): extract shared timeline components and deduplicate code

Extract shared utilities to reduce duplication across timeline components:

- PlayheadIndicator: shared playhead rendering (was duplicated in
  TimelineCanvas and TimelineEditorNotice)
- useContextMenuDismiss: outside-click/Escape dismiss pattern (was
  duplicated in ClipContextMenu and KeyframeDiamondContextMenu)
- TimelineCallbacks: shared callback interfaces for drop and edit
  operations (was duplicated in NLELayout and Timeline props)
- useTimelineZoom: consolidated zoom store selectors
- timelineElementSplit: shared canSplitElement, buildPatchTarget, and
  readFileContent utilities
- gsapParser.test-helpers: shared test utilities for parser specs

* feat(core): GSAP-aware split engine for timeline clip splitting

Add splitAnimationsInScript to the GSAP parser — correctly re-times
animations when a timeline clip is split at an arbitrary position:

- Animations before split: kept on original, properties inherited via
  tl.set inserted before other tweens for correct GSAP state recording
- Animations after split: retargeted via AST selector update
- Spanning animations: trimmed on original, continuation added for
  new element with correct position and duration
- Keyframes: classified by total per-keyframe duration
- Reverse iteration prevents stale animation ID collisions

Enhance splitElementInHtml:
- CSS rule duplication via PostCSS for ID-based styles
- Server-side ID deduplication for repeated splits
- Media playback-start adjustment for video/audio

Add split-animations route to gsap-mutations endpoint.
2026-06-10 23:48:16 -04:00
Miguel Ángel ab08260201 refactor(studio): extract shared timeline components and deduplicate code (#1329)
Extract shared utilities to reduce duplication across timeline components:

- PlayheadIndicator: shared playhead rendering (was duplicated in
  TimelineCanvas and TimelineEditorNotice)
- useContextMenuDismiss: outside-click/Escape dismiss pattern (was
  duplicated in ClipContextMenu and KeyframeDiamondContextMenu)
- TimelineCallbacks: shared callback interfaces for drop and edit
  operations (was duplicated in NLELayout and Timeline props)
- useTimelineZoom: consolidated zoom store selectors
- timelineElementSplit: shared canSplitElement, buildPatchTarget, and
  readFileContent utilities
- gsapParser.test-helpers: shared test utilities for parser specs
2026-06-10 23:45:03 -04:00
Matt Van HornandMatt Van Horn edd85473e7 feat(producer,core): play animated GIF inputs frame-synced via prep-time VP9 transcode (#1335)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-06-10 22:39:19 -04:00
Miguel Ángel 036b991cbb fix(runtime): preserve authored muted attr; clean up WebAudio on end (#1319)
onSetMuted/onSetMediaOutputMuted set el.muted = effective on every
<video> and <audio> element. When the bridge sent onSetMuted(false),
it unmuted avatar <video muted> elements whose baked-in lip-sync audio
should never play — causing double audio alongside the separate TTS.
Fix: el.muted = effective || el.defaultMuted.

AudioBufferSourceNode fires 'ended' when playback completes naturally,
but _activeSources was never cleaned up. This kept isActive() true
permanently, which force-muted all HTML audio elements via the
outputMuted flag in syncRuntimeMedia — causing audio to disappear
after the WebAudio buffer finished (~5s for short TTS clips).

Add onended listener that removes the source from _activeSources and
restores el.muted to its pre-WebAudio value. All side-effects are
guarded by idx !== -1 so a stale ended event after stopAll() is a
no-op and cannot clobber bridge state set between stop and the async
event delivery.
2026-06-10 19:09:13 -04:00
Miguel Ángel f0b499b582 fix(studio): keyframe bug fixes — gate delete hooks, fix value corruption, gesture recording (#1314)
- Gate stripStudioEditsFromTarget/bakeVisibilityOnDelete behind a
  stripStudioEdits flag on the delete mutation type so they only fire on
  user-initiated deletes, not on internal delete-then-recreate drags.

- Add bakeVisibilityOnDelete to the remove-all-keyframes handler so
  elements with CSS opacity:0 stay visible after collapsing keyframes.

- Fix integer rounding in readAllAnimatedProperties: use 3-decimal
  precision for visual properties (opacity, scale, rotation) instead of
  Math.round which corrupted mid-fade values to 0.

- Guard VISUAL_BASELINE against cross-tween contamination by querying
  __timelines for properties animated by other tweens on the same element.

- Harden bakeVisibilityOnDelete: reverse-scan keyframes for the last one
  containing opacity, guard against relative values (+=/-=/*=), and add
  Number.isFinite check.

- Fix falsy-zero doubling in drag commit: replace || fallback with
  Number.isFinite so a base GSAP position of 0 is correctly preserved.

- Fix gesture recording sign inversion: remove pointerElementOffset
  subtraction from dx/dy formula and instead apply it once to basePosition
  so the element center tracks the pointer.

- Fix TypeScript build errors in gsapSoftReload.ts (6 double-casts).

- Strip all diagnostic logs from production code.
2026-06-10 18:53:06 -04:00
Miguel Ángel 192417b7cd fix(core): guard element stamping to iframe-only (Studio preview) 2026-06-09 23:34:08 +00:00
Miguel Ángel a468550f82 feat(studio): keyframe system — parser, runtime, timeline UI, design panel, gesture recording (#1311)
* feat(studio): runtime hooks — global time compiler + keyframe runtime

Add the runtime bridge layer: global time compilation (tween % → clip %),
soft reload after mutations, runtime keyframe preview, and keyframe
commit helper.

* feat(studio): runtime hooks — global time compiler + keyframe runtime

Add the runtime bridge layer: global time compilation (tween % → clip %),
soft reload after mutations, runtime keyframe preview, and keyframe
commit helper.

* feat(studio): keyframe cache + commit hooks

Add hooks for keyframe cache population (tween → clip-relative %),
mutation dispatch, keyframe snapping, and audio beat detection.

* feat(studio): timeline UI — dopesheet diamonds + keyboard nav

Add dopesheet strip with diamond keyframe indicators, timeline property
rows, keyboard navigation (J/Shift+J/Delete/K), and feature gate
(STUDIO_KEYFRAMES_ENABLED defaults to false).

* feat(studio): design panel — arc controls + ease curve + stagger

Add arc path controls (curviness slider, auto-rotate), motion path SVG
overlay, ease curve visualization, stagger controls, and expanded
animation card. Includes border-radius editor dependency from #1217.

* feat(studio): gesture recording core

Add gesture recording engine with RAF sampling, modifier key property
mapping (Shift→rotationXY, Alt→rotation, Cmd→opacity),
Ramer-Douglas-Peucker simplification, and ghost trail SVG overlay.

* fix(studio): keyframe drag + recording bug bash

21 fixes: capture GSAP base at drag start, translate:none before
gsap.set, skip reapplyPathOffsets for GSAP elements, clamp recording
seek, _auto flag for 100% keyframes, overlay flash fix, block edits
during recording.

* feat(studio): keyframe integration wiring + docs

Wire App.tsx recording orchestration, TimelineToolbar K/R buttons,
PropertyPanel per-property diamonds, shortcuts panel, toast
notifications, and keyframes guide documentation. All gated on
STUDIO_KEYFRAMES_ENABLED (default false).
2026-06-09 18:30:23 -04:00
Miguel Ángel 96b8d617d8 feat(studio): GSAP parser — arc path mutations + keyframe CRUD (#1301)
Add parser-level mutations for arc paths, keyframe add/remove/update,
convert-to-keyframes, and _auto flag for 100% keyframes. Wire route
handlers for new mutation types.
2026-06-09 18:28:44 -04:00
Vance IngallsandClaude Sonnet 4.6 0923bc0787 feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b) (#1299)
* feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b)

* refactor(studio): extract readHfId helper, fix empty-string normalization, add comments (R7 review)

- Extract readHfId(el) to domEditingLayers.ts — centralizes `?.trim() || undefined`
  normalisation; guards against empty-string data-hf-id reaching findTagByTarget
- Wire readHfId into domEditingLayers.ts and useDomEditCommits.ts (the one site
  that still used `?? undefined` instead of `|| undefined`)
- Re-export readHfId through domEditing.ts public API
- Add readHfId unit tests: present, absent, empty-string, whitespace-only
- Add comment on PatchTarget: runtime validation lives in findTagByTarget, type is docs-only
- Suppress pre-existing unused re-exports in timelineDOM.ts (backward-compat re-exports
  brought into fallow scope by the T5b hfId changes)

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

* fix(core): clear data-hf-id on split clone to prevent dual-match (R7 review)

cloneNode(true) copies all attributes including data-hf-id. Without clearing it,
both halves of a split share the same hf-id; the server's findByHfId picks the first
match and silently patches the wrong clip. Remove the attribute from the clone so
write-back re-mints a fresh id on the next preview load.

Adds a test: splitElementInHtml — hfId clone isolation.

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

* fix(studio): add hfId to DomEditLayerItem + getDomLayerPatchTarget return type (R7 review)

- Add hfId to DomEditLayerItem interface (domEditingTypes.ts) so layer item
  construction in collectDomEditLayerItems compiles
- Widen getDomLayerPatchTarget return type to include hfId + populate it from
  data-hf-id attribute (domEditingElement.ts)
- Widen findDomEditSelectionTarget to check hfId-first when no id/selector
- Widen Pick types in domEditOverlayGeometry.ts and useGsapScriptCommits.ts
- Add hfId to buildMissingCompositionElements element construction
- Add hfId-targeted test coverage in domEditing.test.ts,
  domEditOverlayGeometry.test.ts, timelineIframeHelpers.test.ts
- Update hfIds.test.ts KNOWN LIMITATION labels — write-back landed in R7 T1-2

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:15:27 -07:00
Vance Ingalls 2071c85c9a feat(studio,core): populate hfId in DomEditSelection + widen MutationTarget (R7, T5a) (#1296)
## Summary

- Adds `hfId` field to `resolveDomEditSelection` — reads `data-hf-id` off the live element and stores it in `DomEditSelection.hfId`
- `DomEditSelection extends PatchTarget` which already declares `hfId?: string`, so this is a single new line at the return site
- Widens `MutationTarget` in `files.ts` to include `hfId?: string` (type hygiene — the value already survives through `parseMutationBody`'s by-reference pass, so this is documentation not a behaviour change)

## Why

R7 / Task 5a. The full hf-id write-back and patch-engine infrastructure (R1 + R7 Tasks 0–4, PRs #1269–#1292) is server-complete. The only missing piece was: the Studio client never read `data-hf-id` off a hit-tested element, so `target.hfId` was always `undefined` and the `hfId`-first lookup branches in both patch engines were unreachable in production. This PR fixes the selection side — the commit wire (#1297) completes the path.

## Test plan

- [ ] `packages/studio/src/components/editor/domEditingLayers.test.ts` — two new tests with jsdom environment:
  - `resolveDomEditSelection` on an element with `data-hf-id` → `selection.hfId` is populated
  - element without `data-hf-id` → `selection.hfId` is `undefined`
- [ ] All 65 studio test files pass, all 72 core test files pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 14:18:14 -07:00
xiayeandClaude Opus 4.7 5a0966dfeb fix(runtime,player): replay bridge state on iframe ready to repair race
The audio-locked attribute was correctly setting `muted = true` and posting
`set-muted` to the iframe runtime, but on warm-cache reloads of claude.ai
and inside the Claude desktop Electron client, the iframe finishes loading
*after* the parent has already sent control messages — the iframe runtime's
postMessage listener isn't installed yet, so the messages are silently
dropped. Audio plays unmuted with no UI to recover.

Confirmed via:
- "First open" on claude.ai: cold cache, iframe slow → listener up before
  `set-muted` lands → audio muted 
- "Hard refresh" on claude.ai: warm cache, iframe fast → listener up after
  message arrives → message lost → audio plays 
- Claude desktop: Electron renderer consistently fast → race always loses
  → audio plays 

Fix: add a `{source: "hf-preview", type: "ready"}` event the runtime emits
once `installRuntimeControlBridge` has registered the listener. The player
listens for it and replays current bridge state (`set-muted`, `set-volume`,
`set-playback-rate`). Pre-ready messages are now safe to send — they'll be
replayed once the runtime can receive them.

The replay is idempotent — re-asserting defaults is a no-op — so it's also
safe across iframe reloads (new runtime instance emits ready again).

Tests: 6 new (1 bridge: ready posted on install; 5 player: replays muted /
volume / playback-rate / audio-locked-forced-mute / handles second ready /
ignores ready from wrong source). Suites green: core 1387, player 137.

Refs:
- Investigation: heygen-com/hyperframes#1300 (UA-fallback attempt — unrelated
  to actual root cause)
- claude.ai-web.log analysis revealed cross-origin iframe + race condition,
  not attribute stripping as originally hypothesized

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 12:22:27 -07:00
Vance IngallsandClaude Sonnet 4.6 3d7d7c0291 refactor(core,studio): extract draft-marker constants + R7 code-review fixes (Task 4) (#1292)
* refactor(core,studio): extract draft-marker constants to core (R7, Task 4)

Create draftMarkers.ts in core with 5 shared CSS custom property names and the
gesture DOM attribute. PreviewAdapter imports from draftMarkers.ts instead of
hardcoding strings. Adds @hyperframes/core/studio-api/draft-markers export
subpath. Studio's manualEditsTypes.ts re-exports the shared constants from core
so all existing call sites are unchanged.

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

* fix(core): address R7 code-review findings (C1–C14, P6–P7)

- previewAdapter: auto-revert previous gesture in applyDraft (C3); clearDraftProps
  on commitPreview not just revertDraft (C4); isVisible NaN→visible for JSDOM (P7);
  remove redundant GestureState.hfId field (C12); remove Array.from (C14);
  extract clearDraftProps/revertGesture helpers (C5/C6)
- hfIdPersist: replace string-equality change detection with attribute count to
  avoid false-positive writes on single-quoted HTML (C1); re-read disk before
  write for TOCTOU guard (C7); remove normalizeHfIds wrapper (C11)
- preview.ts: remove dead null-check on normalizedDisk after diskMain guard (C9);
  catch path re-reads disk fresh instead of using stale pre-request snapshot (C8)
- hfIds.test.ts: replace tautological second stability test with cross-document
  content-keyed id stability test (P6)

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

* fix(core): follow-up R7 review fixes — CSS.escape fallback, invariant docs, new edge-case tests

- hfIdPersist: remove ensureHfIds re-export (P2); add JSDoc invariant note;
  improve TOCTOU comment; pass err to console.warn
- preview.ts: split import — ensureHfIds from parsers/hfIds.js (not re-export)
- previewAdapter: CSS.escape + inline fallback for non-browser environments;
  add JSDoc for atTime caller-seek contract; add 0.01 opacity-threshold comment
- previewAdapter.test: rename atTime test to clarify adapter-does-not-seek;
  add nested-hf-root-without-id test; add resize→move prop-leak test;
  add revertDraft-after-commit no-op test

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

* test(core): bundle-vs-disk id-stability test; comment double ensureHfIds (P3)

- preview.test: add "bundle returning untagged HTML gets same ids as disk" test —
  guards against id divergence when bundler reads a pre-write cache snapshot;
  content-keyed FNV1a minting ensures served ids == disk ids for same source HTML
- preview.ts: comment the second ensureHfIds call explaining it's intentional for
  adapter-injected elements and idempotent on the no-bundle path (P3 from miguel)

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

* docs(core): wire-contract comment on mintHfId + fallow suppressions (R7)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 01:13:54 -07:00
Vance IngallsandClaude Sonnet 4.6 1a23938bef feat(core): implement createPreviewAdapter (R7, Task 3) (#1291)
* test(core): data-hf-id survives id/selector patch (R1, T7)

Locks the preservation guarantee the write-back design depends on: a
Studio edit targeting by id or selector (it never sends hfId) must not strip
an existing data-hf-id, or the stable handle is destroyed by the next edit.

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

* fix(core): escape hfId in selector + warn on duplicate match (R1, T7 review)

Addresses review on #1272 (Miguel P3 + Rames): findTargetElement interpolated
target.hfId raw into a [data-hf-id="..."] selector. Escape it (CSS attr-value
injection guard) and warn when a hfId matches more than one element instead of
silently patching an arbitrary one. Adds an injection-guard test.

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

* feat(core): implement createPreviewAdapter — greens 20 T10 tests (R7, Task 3)

elementAtPoint: resolvePoint callback → walk ancestors for data-hf-id,
skip data-hf-root without data-hf-id (stage root), skip opacity-0 elements.

applyDraft: find element by hfId, record originalTranslate, set
--hf-studio-offset-x/y (move) or --hf-studio-width/height (resize),
mark data-hf-studio-manual-edit-gesture.

revertDraft: remove draft CSS props, clear gesture marker, restore
originalTranslate if one was recorded.

commitPreview: extract patch (move→moveElement, resize→resize with w/h
renamed to width/height), clear gesture marker, return patch or null.

getElementTimings: scan [data-hf-id] elements, parse data-start/data-end
as floats, return map with undefined fields for absent attributes.

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

* fix(core): remove explicit data-hf-id from htmlParser tests so ensureHfIds mints hf- ids

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:39:53 -07:00
Vance IngallsandClaude Sonnet 4.6 740a83abb3 feat(core): hf-id write-back to disk + serve-time surfacing (R7, Tasks 1-2) (#1289)
* feat(core): clip-model hf- ids minted at parse, emitted as data-hf-id (R1)

* docs(core): document legacy-id round-trip in clip-model readback (R1 review)

Addresses Rames' review on #1270: clarifies that a pre-R1 clip authored with
id="my-title" round-trips as data-hf-id="my-title" (non-hf-shaped but stable,
exact-match) by design — targeting uses exact [data-hf-id="…"] match and does
not require the hf- shape; legacy values re-mint only at the R7 write-back. Not
a bug. Comment-only.

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

* docs(core): fix misleading legacy-id migration comment in htmlParser.ts

The original comment said legacy data-hf-id values "are re-minted only
once the R7 write-back persists freshly-minted ids to source" — which is
incorrect. ensureHfIds skips elements that already carry data-hf-id, so
legacy values (e.g. data-hf-id="my-title") persist indefinitely and are
NOT automatically re-minted. Exact-match targeting still works correctly.
Update comment to reflect actual behaviour.

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

* feat(studio): sourcePatcher data-hf-id targeting (R1, T3)

* fix(studio): warn on duplicate match in execDataAttrPattern (R1, T3 review)

Addresses Rames' review on #1271: execDataAttrPattern returned the first regex
match without checking for a second. A duplicate id/data-hf-id in source (id
drift) would silently patch one element and leave the other stale. Now warns
when more than one element matches. By the mint contract it should never fire.

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

* test(studio): pin hfId-is-authoritative-over-selector contract (R1, T3 review)

Adds test: "hfId match is authoritative — selector is not used as a
narrowing filter". When hfId matches element A and selector points at
element B, findTagByTarget returns A without consulting selector as a
narrowing filter. Pins the intended behaviour so a future refactor
cannot silently start narrowing by selector.

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

* feat(core): sourceMutation data-hf-id targeting (R1, T7)

* test(core): update htmlParser baselines for R1 hf- id format

Elements now get data-hf-id minted by ensureHfIds; parser reads
data-hf-id as model id, so HTML id attrs are no longer the model id.

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

* test(core): data-hf-id survives id/selector patch (R1, T7)

Locks the preservation guarantee the write-back design depends on: a
Studio edit targeting by id or selector (it never sends hfId) must not strip
an existing data-hf-id, or the stable handle is destroyed by the next edit.

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

* fix(core): escape hfId in selector + warn on duplicate match (R1, T7 review)

Addresses review on #1272 (Miguel P3 + Rames): findTargetElement interpolated
target.hfId raw into a [data-hf-id="..."] selector. Escape it (CSS attr-value
injection guard) and warn when a hfId matches more than one element instead of
silently patching an arbitrary one. Adds an injection-guard test.

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

* test(core): previewAdapter contract failing tests (T10 spec for R7)

* feat(core): hf-id write-back to disk + serve-time surfacing (R7, Task 1-2)

* test(core): replace tautological stability tests with real disk tests for persistHfIdsIfNeeded

Prior tests only exercised normalizeHfIds (pure function) and the existing
pin guard in ensureHfIds — both pass on the parent commit without any Task 1
code. Replace with three tests that exercise the actual disk write-back:

- writes data-hf-id to disk when source is untagged
- does not rewrite disk when source is already tagged (idempotent)
- returned id matches id written to disk (serve-time == persist-time invariant)

These fail on the parent commit (persistHfIdsIfNeeded doesn't exist) and
green after Task 1.

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

* test(core): route-level tests for data-hf-id surfacing and disk write-back (R7, Task 1-2)

Two integration tests against the preview route (via Hono test harness):

- served HTML carries data-hf-id on body elements (>= 2 matches for div+p)
- disk file contains data-hf-id after first GET (write-back verified via readFileSync)

These fail on the parent commit (no hfIdPersist wiring in preview.ts) and
green after Task 1. Closes the verification gap flagged in review.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:28:52 -07:00
Vance IngallsandClaude Opus 4.8 72e4f1a0f6 test(core): previewAdapter contract failing tests (T10 spec for R7) (#1286)
* feat(core): clip-model hf- ids minted at parse, emitted as data-hf-id (R1)

* docs(core): document legacy-id round-trip in clip-model readback (R1 review)

Addresses Rames' review on #1270: clarifies that a pre-R1 clip authored with
id="my-title" round-trips as data-hf-id="my-title" (non-hf-shaped but stable,
exact-match) by design — targeting uses exact [data-hf-id="…"] match and does
not require the hf- shape; legacy values re-mint only at the R7 write-back. Not
a bug. Comment-only.

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

* docs(core): fix misleading legacy-id migration comment in htmlParser.ts

The original comment said legacy data-hf-id values "are re-minted only
once the R7 write-back persists freshly-minted ids to source" — which is
incorrect. ensureHfIds skips elements that already carry data-hf-id, so
legacy values (e.g. data-hf-id="my-title") persist indefinitely and are
NOT automatically re-minted. Exact-match targeting still works correctly.
Update comment to reflect actual behaviour.

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

* feat(studio): sourcePatcher data-hf-id targeting (R1, T3)

* fix(studio): warn on duplicate match in execDataAttrPattern (R1, T3 review)

Addresses Rames' review on #1271: execDataAttrPattern returned the first regex
match without checking for a second. A duplicate id/data-hf-id in source (id
drift) would silently patch one element and leave the other stale. Now warns
when more than one element matches. By the mint contract it should never fire.

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

* test(studio): pin hfId-is-authoritative-over-selector contract (R1, T3 review)

Adds test: "hfId match is authoritative — selector is not used as a
narrowing filter". When hfId matches element A and selector points at
element B, findTagByTarget returns A without consulting selector as a
narrowing filter. Pins the intended behaviour so a future refactor
cannot silently start narrowing by selector.

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

* feat(core): sourceMutation data-hf-id targeting (R1, T7)

* test(core): update htmlParser baselines for R1 hf- id format

Elements now get data-hf-id minted by ensureHfIds; parser reads
data-hf-id as model id, so HTML id attrs are no longer the model id.

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

* test(core): data-hf-id survives id/selector patch (R1, T7)

Locks the preservation guarantee the write-back design depends on: a
Studio edit targeting by id or selector (it never sends hfId) must not strip
an existing data-hf-id, or the stable handle is destroyed by the next edit.

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

* fix(core): escape hfId in selector + warn on duplicate match (R1, T7 review)

Addresses review on #1272 (Miguel P3 + Rames): findTargetElement interpolated
target.hfId raw into a [data-hf-id="..."] selector. Escape it (CSS attr-value
injection guard) and warn when a hfId matches more than one element instead of
silently patching an arbitrary one. Adds an injection-guard test.

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

* test(core): previewAdapter contract failing tests (T10 spec for R7)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:28:21 -07:00
Vance IngallsandClaude Opus 4.8 1cd4ad6ab8 feat(core): sourceMutation data-hf-id targeting (R1, T7) (#1272)
* feat(core): clip-model hf- ids minted at parse, emitted as data-hf-id (R1)

* docs(core): document legacy-id round-trip in clip-model readback (R1 review)

Addresses Rames' review on #1270: clarifies that a pre-R1 clip authored with
id="my-title" round-trips as data-hf-id="my-title" (non-hf-shaped but stable,
exact-match) by design — targeting uses exact [data-hf-id="…"] match and does
not require the hf- shape; legacy values re-mint only at the R7 write-back. Not
a bug. Comment-only.

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

* docs(core): fix misleading legacy-id migration comment in htmlParser.ts

The original comment said legacy data-hf-id values "are re-minted only
once the R7 write-back persists freshly-minted ids to source" — which is
incorrect. ensureHfIds skips elements that already carry data-hf-id, so
legacy values (e.g. data-hf-id="my-title") persist indefinitely and are
NOT automatically re-minted. Exact-match targeting still works correctly.
Update comment to reflect actual behaviour.

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

* feat(studio): sourcePatcher data-hf-id targeting (R1, T3)

* fix(studio): warn on duplicate match in execDataAttrPattern (R1, T3 review)

Addresses Rames' review on #1271: execDataAttrPattern returned the first regex
match without checking for a second. A duplicate id/data-hf-id in source (id
drift) would silently patch one element and leave the other stale. Now warns
when more than one element matches. By the mint contract it should never fire.

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

* test(studio): pin hfId-is-authoritative-over-selector contract (R1, T3 review)

Adds test: "hfId match is authoritative — selector is not used as a
narrowing filter". When hfId matches element A and selector points at
element B, findTagByTarget returns A without consulting selector as a
narrowing filter. Pins the intended behaviour so a future refactor
cannot silently start narrowing by selector.

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

* feat(core): sourceMutation data-hf-id targeting (R1, T7)

* test(core): update htmlParser baselines for R1 hf- id format

Elements now get data-hf-id minted by ensureHfIds; parser reads
data-hf-id as model id, so HTML id attrs are no longer the model id.

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

* test(core): data-hf-id survives id/selector patch (R1, T7)

Locks the preservation guarantee the write-back design depends on: a
Studio edit targeting by id or selector (it never sends hfId) must not strip
an existing data-hf-id, or the stable handle is destroyed by the next edit.

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

* fix(core): escape hfId in selector + warn on duplicate match (R1, T7 review)

Addresses review on #1272 (Miguel P3 + Rames): findTargetElement interpolated
target.hfId raw into a [data-hf-id="..."] selector. Escape it (CSS attr-value
injection guard) and warn when a hfId matches more than one element instead of
silently patching an arbitrary one. Adds an injection-guard test.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:27:52 -07:00
Vance IngallsandClaude Sonnet 4.6 8d6b7cf515 feat(core): clip-model hf- ids minted at parse, emitted as data-hf-id (R1) (#1270)
* feat(core): clip-model hf- ids minted at parse, emitted as data-hf-id (R1)

* docs(core): document legacy-id round-trip in clip-model readback (R1 review)

Addresses Rames' review on #1270: clarifies that a pre-R1 clip authored with
id="my-title" round-trips as data-hf-id="my-title" (non-hf-shaped but stable,
exact-match) by design — targeting uses exact [data-hf-id="…"] match and does
not require the hf- shape; legacy values re-mint only at the R7 write-back. Not
a bug. Comment-only.

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

* docs(core): fix misleading legacy-id migration comment in htmlParser.ts

The original comment said legacy data-hf-id values "are re-minted only
once the R7 write-back persists freshly-minted ids to source" — which is
incorrect. ensureHfIds skips elements that already carry data-hf-id, so
legacy values (e.g. data-hf-id="my-title") persist indefinitely and are
NOT automatically re-minted. Exact-match targeting still works correctly.
Update comment to reflect actual behaviour.

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

* test(core): update htmlParser tests for R1 hf- id minting

Pre-R1 tests expected clip ids to reflect legacy `id=` attributes.
After R1, ensureHfIds runs first and mints data-hf-id — so clip.id
reflects the minted hf- value unless the element already has data-hf-id.

Fix: add explicit data-hf-id to test HTML elements where tests assert
specific id values. Update no-id test to expect hf- format (/^hf-[a-z0-9]{4}$/)
instead of the pre-R1 generated-id fallback (/^element-\d+$/).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:19:31 -07:00
Vance Ingalls c12854b47c feat(core): ensureHfIds node-id pass + shared hf- mint helper (R1) (#1269)
## What

Adds `ensureHfIds(html: string): string` in `packages/core/src/parsers/hfIds.ts`. Single DOM pass (via linkedom) that mints a `data-hf-id` attribute on every eligible element before the caller sees the markup.

**Id derivation:** FNV-1a 32-bit hash of `tagName | sorted-attrs(\x00/\x01 separated) | ownText`, last 4 chars of base-36, `hf-` prefix. Collision resolution appends a sibling counter and re-hashes. Preserves existing ids (elements with `data-hf-id` already set are skipped). Excludes non-visual tags: `script`, `style`, `template`, `meta`, `link`, `noscript`, `base`.

**Fragment handling:** detects bare HTML fragments (no `<!doctype` / `<html`) and wraps in a full document shell before parsing, then returns `body.innerHTML` — matching the pattern used by `parseSourceDocument` in `sourceMutation`.

## Why

Counter-based ids (`element-0`, `element-1`, …) are positional. Inserting a new layer at position 0 shifts every id below it. The R1 milestone requires content-based, stable ids so that targeting operations (split, patch, probe) stay valid across re-parses and element insertions. T2 spec (`stableIds.test.ts`) defines the contract: same content → same id, adding a sibling doesn't change other ids, format matches `/^hf-[a-z0-9]{4}$/`.

## How

- `toHfId(hash)` — `slice(-4)` of `hash.toString(36)` for better distribution across the suffix space
- `data-hf-id` is excluded from the hash input (prevents circular dependency on the attribute being set)
- Already-assigned ids tracked in a `Set`; duplicates get a counter suffix before re-hashing
- Fragment detection: `/<!doctype|<html[\s>]/i.test(html)` — if bare, wrap→parse→`body.innerHTML`

## Test plan

- [x] T2 spec: `packages/core/src/parsers/stableIds.test.ts` — 7 tests pass (3 were `.fails` stubs targeting R1; 4 were pre-existing baselines that must not regress)
- [x] No changes to existing htmlParser tests needed at this layer (wiring is PR #1270)
2026-06-08 23:28:25 -07:00
Miguel Ángel 908b455e5e fix(core): apply playbackRate to all media duration resolution sites (#1288)
Extract readElementPlaybackRate() to eliminate clamping duplication across
media.ts, init.ts, startResolver.ts, and timeline.ts. Apply the rate
division to the two remaining sites that were missed:

- startResolver.ts: visibility loop used raw source duration, hiding
  slowed-down videos mid-playback when no data-duration was set
- timeline.ts: resolveMediaElementDurationSeconds underreported the end
  window sent to the renderer, affecting preview parity

Also adds direct tests for readElementPlaybackRate().
2026-06-08 21:29:04 -04:00
Miguel Ángel 7b6c87fa88 fix(core): account for playbackRate in media duration resolution (#1287)
* fix(core): account for playbackRate in media duration resolution

resolveDurationSeconds computed sourceDuration as (element.duration - mediaStart)
without dividing by playbackRate. A 5s source at 0.5x should span 10s on the
timeline, but was capped at 5s — causing the video to go black once the raw
source was exhausted.

Read defaultPlaybackRate from the element (same clamping as refreshRuntimeMediaCache)
and divide sourceDuration by it so the effective timeline window matches the
actual playback speed.

* test(core): add regression test for playbackRate in resolveDurationSeconds

Pins the fix: a 5s source at 0.5x playbackRate must resolve to 10s effective
duration when resolveDurationSeconds is provided (mirroring the init.ts callback
pattern). Without the rate division, this would return 5s and clip early.
2026-06-08 21:17:41 -04:00
Miguel Ángel 1bcd6ec3b3 fix(core): re-register hf-timelines-built listener in maybePublishRenderReady (#1279)
Compositions that defer gsap.timeline() registration past DOMContentLoaded
(via setTimeout, template instantiation, or dynamic script loading) hit a
race where __renderReady stays false forever:

1. At DOMContentLoaded, __hfTimelinesBuilding is false — init.ts skips
   the hf-timelines-built listener and sets __renderReady = true
2. The deferred script runs, calls gsap.timeline().to() which sets
   __hfTimelinesBuilding = true via the batching proxy
3. The deferred maybePublishRenderReady() sees building=true, sets
   __renderReady = false, but never registers a listener to retry
4. __renderReady stays false, __hf.duration returns 0, pollHfReady
   times out with "Composition has zero duration"

Fix: when maybePublishRenderReady encounters __hfTimelinesBuilding=true,
register a one-shot hf-timelines-built listener to retry — matching the
pattern already used at init time for the synchronous batching case.

Closes #1260
2026-06-08 16:45:40 -04:00
Vance Ingalls 63fd322df1 test(core): address review feedback on T6a / T10 / T7 stubs (#1268)
T6a (#1263): add fromTo corpus script (third parser method) with negative
position values to exercise UnaryExpression arm; drop unused breatheRepeats
from COMPLEX_SCRIPT; generate fromto.parsed.json + fromto.serialized.js goldens.

T10 (#1262): split applyDraft move/resize into two stubs; add applyDraft edge-
case describe block (concurrent gestures, idempotent revert, playhead-change
stability, nested sub-composition root); add getElementTimings stub for absent
data-start/end on a data-hf-id element.

T7 (#1267): expand to full parity with T3 — add text-content, attribute, and
fallthrough stubs (Core sourceMutation supports all patch types via
patchElementInHtml).
2026-06-07 19:57:57 -07:00
Vance Ingalls 1e705b1107 test(core,studio): add T3+T7 hfId targeting stubs (spec for R1) (#1267)
* test(studio): add T5b rotation+motion build-patches characterization

Extends manualEditsDomPatches.test.ts with rotation and motion pairs.
Same 4-pattern structure: populated, empty, clear restores originals,
build/clear symmetry. Merges duplicate manualEditsTypes import block.

* test(studio): add T5c review-fix gaps in manualEditsDomPatches characterization

Fixes four gaps identified in max-setting code review:
- Box-size clear: replace arrayContaining with full ordered toEqual (30 ops)
- Box-size / pathOffset / rotation clear: add empty-string coercion tests
  (origVal||null must produce null, not set property to "")
- Rotation clear: add test for absent STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR
- Motion clear: prove input-independence by calling with both empty and populated
  element and asserting identical output

* refactor(core): extract maxEndTime+serialize to parsers/test-utils.ts (TU)

Deduplicate helpers shared by T1 (htmlParser.roundtrip.test.ts) and T2
(stableIds.test.ts). Both files inline identical implementations; extract
to test-utils.ts so future parser tests (T6a…) import one copy.

Also fix lefthook fallow command to unset GIT_DIR+GIT_INDEX_FILE before
running — those vars are set by git in worktree hook context and block
fallow’s internal temp-worktree creation.

* test(core): add T10 PreviewAdapter contract stubs (spec for R7)

All 14 tests are it.todo, following the T4 pattern. The stubs define the
full createPreviewAdapter interface — elementAtPoint (root exclusion,
hf-id ancestor walk, opacity filter), applyDraft/revertDraft (draft
marker lifecycle), commitPreview (patch derivation), and getElementTimings
(data-start/data-end reader).

createPreviewAdapter does not exist yet; R7 implements it and converts
these stubs to real assertions.

* test(core): add T6a GSAP parser golden baselines (Recast/Babel snapshot)

6 toMatchFileSnapshot tests across 3 representative scripts (minimal,
moderate, complex). Captures parseGsapScript + serializeGsapAnimations
output before the Recast → Meriyah swap so any parser change is detected
as a golden diff rather than a silent behavioral regression.

Goldens live in src/parsers/__goldens__/ and are checked in. Add
__goldens__/** to fallow ignorePatterns (data files, not modules) and to
.prettierignore so oxfmt does not reformat vitest-written snapshot files.

* test(core,studio): add T3+T7 hfId targeting stubs (spec for R1)

T3 (sourcePatcher.test.ts): 5 it.todo stubs for PatchTarget.hfId targeting
— style, text, attribute patches plus preservation and fallthrough cases.

T7 (sourceMutation.test.ts): 2 it.todo stubs for SourceMutationTarget.hfId
— basic patch and data-hf-id survival after patch.

Neither interface has hfId yet. R1 adds the field + [data-hf-id="…"] branch
in findTagByTarget / findTargetElement, then converts these to real assertions.
2026-06-07 19:23:27 -07:00
Vance Ingalls 184ef03317 test(core): add T6a GSAP parser golden baselines (Recast/Babel snapshot) (#1263)
* test(studio): add T5b rotation+motion build-patches characterization

Extends manualEditsDomPatches.test.ts with rotation and motion pairs.
Same 4-pattern structure: populated, empty, clear restores originals,
build/clear symmetry. Merges duplicate manualEditsTypes import block.

* test(studio): add T5c review-fix gaps in manualEditsDomPatches characterization

Fixes four gaps identified in max-setting code review:
- Box-size clear: replace arrayContaining with full ordered toEqual (30 ops)
- Box-size / pathOffset / rotation clear: add empty-string coercion tests
  (origVal||null must produce null, not set property to "")
- Rotation clear: add test for absent STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR
- Motion clear: prove input-independence by calling with both empty and populated
  element and asserting identical output

* refactor(core): extract maxEndTime+serialize to parsers/test-utils.ts (TU)

Deduplicate helpers shared by T1 (htmlParser.roundtrip.test.ts) and T2
(stableIds.test.ts). Both files inline identical implementations; extract
to test-utils.ts so future parser tests (T6a…) import one copy.

Also fix lefthook fallow command to unset GIT_DIR+GIT_INDEX_FILE before
running — those vars are set by git in worktree hook context and block
fallow’s internal temp-worktree creation.

* test(core): add T10 PreviewAdapter contract stubs (spec for R7)

All 14 tests are it.todo, following the T4 pattern. The stubs define the
full createPreviewAdapter interface — elementAtPoint (root exclusion,
hf-id ancestor walk, opacity filter), applyDraft/revertDraft (draft
marker lifecycle), commitPreview (patch derivation), and getElementTimings
(data-start/data-end reader).

createPreviewAdapter does not exist yet; R7 implements it and converts
these stubs to real assertions.

* test(core): add T6a GSAP parser golden baselines (Recast/Babel snapshot)

6 toMatchFileSnapshot tests across 3 representative scripts (minimal,
moderate, complex). Captures parseGsapScript + serializeGsapAnimations
output before the Recast → Meriyah swap so any parser change is detected
as a golden diff rather than a silent behavioral regression.

Goldens live in src/parsers/__goldens__/ and are checked in. Add
__goldens__/** to fallow ignorePatterns (data files, not modules) and to
.prettierignore so oxfmt does not reformat vitest-written snapshot files.
2026-06-07 19:23:06 -07:00
Vance Ingalls 1fdce71c8b test(core): add T10 PreviewAdapter contract stubs (spec for R7) (#1262)
* test(studio): add T5b rotation+motion build-patches characterization

Extends manualEditsDomPatches.test.ts with rotation and motion pairs.
Same 4-pattern structure: populated, empty, clear restores originals,
build/clear symmetry. Merges duplicate manualEditsTypes import block.

* test(studio): add T5c review-fix gaps in manualEditsDomPatches characterization

Fixes four gaps identified in max-setting code review:
- Box-size clear: replace arrayContaining with full ordered toEqual (30 ops)
- Box-size / pathOffset / rotation clear: add empty-string coercion tests
  (origVal||null must produce null, not set property to "")
- Rotation clear: add test for absent STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR
- Motion clear: prove input-independence by calling with both empty and populated
  element and asserting identical output

* refactor(core): extract maxEndTime+serialize to parsers/test-utils.ts (TU)

Deduplicate helpers shared by T1 (htmlParser.roundtrip.test.ts) and T2
(stableIds.test.ts). Both files inline identical implementations; extract
to test-utils.ts so future parser tests (T6a…) import one copy.

Also fix lefthook fallow command to unset GIT_DIR+GIT_INDEX_FILE before
running — those vars are set by git in worktree hook context and block
fallow’s internal temp-worktree creation.

* test(core): add T10 PreviewAdapter contract stubs (spec for R7)

All 14 tests are it.todo, following the T4 pattern. The stubs define the
full createPreviewAdapter interface — elementAtPoint (root exclusion,
hf-id ancestor walk, opacity filter), applyDraft/revertDraft (draft
marker lifecycle), commitPreview (patch derivation), and getElementTimings
(data-start/data-end reader).

createPreviewAdapter does not exist yet; R7 implements it and converts
these stubs to real assertions.
2026-06-07 19:22:34 -07:00
Vance Ingalls ffc54c7867 refactor(core): extract maxEndTime+serialize to parsers/test-utils.ts (TU) (#1261)
* test(studio): add T5b rotation+motion build-patches characterization

Extends manualEditsDomPatches.test.ts with rotation and motion pairs.
Same 4-pattern structure: populated, empty, clear restores originals,
build/clear symmetry. Merges duplicate manualEditsTypes import block.

* test(studio): add T5c review-fix gaps in manualEditsDomPatches characterization

Fixes four gaps identified in max-setting code review:
- Box-size clear: replace arrayContaining with full ordered toEqual (30 ops)
- Box-size / pathOffset / rotation clear: add empty-string coercion tests
  (origVal||null must produce null, not set property to "")
- Rotation clear: add test for absent STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR
- Motion clear: prove input-independence by calling with both empty and populated
  element and asserting identical output

* refactor(core): extract maxEndTime+serialize to parsers/test-utils.ts (TU)

Deduplicate helpers shared by T1 (htmlParser.roundtrip.test.ts) and T2
(stableIds.test.ts). Both files inline identical implementations; extract
to test-utils.ts so future parser tests (T6a…) import one copy.

Also fix lefthook fallow command to unset GIT_DIR+GIT_INDEX_FILE before
running — those vars are set by git in worktree hook context and block
fallow’s internal temp-worktree creation.
2026-06-07 19:22:07 -07:00
Miguel Ángel 0bf15119f8 feat: font resolution pipeline — compositions capture and embed their own fonts (#1255)
Compositions are now self-contained: the compiler captures font files
and embeds them as woff2 data URIs, eliminating silent render-time
fallback when the render environment lacks the author's fonts.

Resolution order (each tier falls through to the next):
1. Existing @font-face → use as-is
2. Bundled alias (38 cross-platform mappings) → embed data URI
3. Google Fonts → fetch, cache, embed
4. Local system font → locate on OS, compress to woff2, embed
5. Local @font-face paths → read file, compress, inline as data URI
6. External CDN stylesheets → fetch CSS, extract @font-face, inline
7. Alias map fallback → closest bundled equivalent
8. Actionable error with guidance

Key changes:
- System font locator (macOS/Windows/Linux) with path-bounding and
  symlink defense (realpathSync + O_NOFOLLOW)
- woff2 compression via wawoff2 (WASM, cross-platform)
- Multi-weight/style variant capture with length-sorted token matching
- External stylesheet inlining with SSRF defense (assertPublicHttpsUrl,
  HTTPS-only, private-host blocking, 2MB cap, 4-concurrent limit)
- Studio auto-import via GET /fonts/file API + renderAliasFor() derived
  from shared FONT_ALIAS_MAP (no more hand-curated drift)
- failClosedFontFetch throws on unresolved fonts in distributed renders
- Single source of truth: @hyperframes/core/fonts/aliases
- system_font_will_alias lint rule (escalates to warning for distributed)
- Default to Inter + JetBrains Mono in templates and CSS reset
2026-06-07 17:52:19 -04:00
Vance IngallsandClaude Sonnet 4.6 806b226b34 test(core): add T9 CompositionVariable font/image parse spec (before R1) (#1246)
* test(core): add T9 CompositionVariable font/image parse spec (before R1)

* fix(core): mark font+image spec tests as it.fails() — not yet implemented

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 11:41:21 -07:00
48fcf4ada5 feat(registry): add morph-text component and text-effects catalog section (#1247)
* feat(gsap): add innerText support to GSAP inspector for counter animations (#1244)

Adds 'innerText' as a supported GSAP property so number roll-up animations
(count-up from 0 to some value) are visible and editable in the GSAP inspector
panel.

- Add 'innerText' to SUPPORTED_PROPS in gsapConstants.ts
- Add label 'Counter Value', tooltip, and step constraint (1) in
  gsapAnimationConstants.ts

The snap modifier that controls integer rounding is already preserved
verbatim via the EXTRAS_KEYS round-trip, so rounding behavior survives
edits without any additional UI changes.

Closes #1179

* feat(registry): add text-effects catalog section and morph-text component

Introduces a new "Text Effects" catalog section (below Effects) for text-focused visual components.

- Add `text-effects` BlockCategory to core registry types with violet color
- Add `text-effect` tag resolver in resolveBlockCategory (checked before generic `effect` tag)
- Tag caption-blend-difference, texture-mask-text, and morph-text with `text-effect`
- Update studio catalog order and color map to include text-effects
- Add morph-text component: gooey SVG threshold morph cycling through editable statements
  using GSAP seekable proxy pattern for deterministic/seekable rendering

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

* chore(registry): add morph-text preview video

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

* chore(registry): fix morph-text.html formatting

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

* docs(catalog): add Text Effects section and morph-text page

Moves caption-blend-difference and texture-mask-text out of Effects into a new
"Text Effects" section below it. Adds morph-text component page with install
instructions and preview video.

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

* chore(registry): add demo.html for morph-text catalog preview rendering

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

* fix(registry): address PR review feedback on morph-text and text-effects

- Restore `effect` tag on caption-blend-difference and texture-mask-text
  alongside `text-effect` so existing tag-equality searches/analytics still match
- Fix morphPause script fallback from "0.25" to "1.5" to match data attribute default
- Add Math.max(0, ...) guard to blur values (intent clarity)
- Add prefers-reduced-motion: skip morph and show first word statically
- Remove CATEGORY_ORDER record from useBlockCatalog; derive order from
  BLOCK_CATEGORIES array (single source of truth, no drift)
- Add comment to demo.html documenting its purpose (catalog preview script only)

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

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 10:55:13 -07:00
Vance IngallsandClaude Sonnet 4.6 a890d093e1 test(core): add T2 stable id spec for parse-to-hf id contract (before R1) (#1245)
* test(studio): add T4 op-contract stubs for editor dispatch boundary

* test(core): add T2 stable id spec for parse-to-hf id contract (before R1)

* test(core): mark pre-R1 spec tests as it.fails so CI passes

The three [spec] tests document intended R1 behavior that the parser
does not yet implement. Using it.fails() makes them green while the
spec is pre-R1; they will flip red again once R1 lands and starts
returning hf- prefixed ids.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 10:38:48 -07:00
Miguel Ángel ebd156bcc1 fix: batch GSAP timeline construction to prevent main-thread hang (#1231) (#1249)
* fix: batch GSAP timeline construction to prevent main-thread hang (#1231)

Compositions with thousands of tl.to() calls (e.g. 8,562 in the
reported case) block Chrome's main thread synchronously during HTML
parsing, preventing DOMContentLoaded from firing before Puppeteer's
navigation timeout. This caused render jobs to hang indefinitely at
'Initializing calibration session...' with no error message.

Root cause: GSAP's timeline API is synchronous — each tl.to() call
registers a tween immediately on the main thread. A script with 8k+
calls holds the thread for seconds, starving the browser event loop and
delaying DCL past the navigation timeout window.

Fix: install a property trap on window.gsap in HF_EARLY_STUB (injected
at the top of <head>, before GSAP or user scripts load). When GSAP
assigns itself to window.gsap, the setter intercepts the real gsap
object and wraps gsap.timeline() to return a proxy that queues tween
descriptors (to/from/fromTo/set) instead of calling them synchronously.
A requestAnimationFrame-based flush loop drains 100 tweens per frame,
yielding the main thread between batches so DCL can fire.

When the queue is drained, the stub sets window.__hfTimelinesBuilding =
false and dispatches a 'hf-timelines-built' CustomEvent. init.ts checks
this flag at DOMContentLoaded time; if building is still in progress it
defers bindRootTimelineIfAvailable() until the event fires, then sets
window.__renderReady = true as normal. pollHfReady continues to gate
on both __renderReady and window.__hf.duration > 0, so the render
pipeline does not start until the full timeline is bound.

- Batch size: 100 tweens/rAF tick (empirical; ~4ms/batch at 8k scale)
- Yield mechanism: requestAnimationFrame (cooperative, no setTimeout(0))
- Determinism: 'hf-timelines-built' event guarantees sequencing
- Proxy forwards: pause/seek/totalTime/time/duration/add/paused/
  timeScale/play delegate to the real timeline immediately
- No GSAP package changes; no navigation timeout increase

Fixes #1231

* style: apply oxfmt formatting to producer stub files

* fix(producer): unwrap proxy children in add(), gate setter return on args.length

Addresses two latent correctness concerns from code review:

1. proxy.add() now unwraps __hfReal from any proxy child before passing it
   to the real timeline. GSAP's internal tween graph (_first/_next/_prev
   linkage) requires real timeline instances — proxy objects lack internal
   fields like _dp that GSAP's iteration paths expect.

2. totalTime/time/paused/timeScale now return proxy when called in setter form
   (args.length > 0). Previously these returned the real timeline, causing
   callers who chain .to(...) after a setter call to bypass batching.

Also: build-hf-early-stub.ts now runs oxfmt on the generated output file
so the format check passes in CI on every build.

* fix(producer): gate __hf.duration=0 while GSAP timelines are batching

The HF_BRIDGE_SCRIPT duration getter now returns 0 whenever
window.__hfTimelinesBuilding is true (set by HF_EARLY_STUB while the rAF
batch loop is draining queued tl.to() calls).

pollHfReady in the engine polls until window.__hf.duration > 0, so
returning 0 keeps the engine waiting until the hf-timelines-built event
fires and all tweens are committed to the real GSAP timelines.

Without this gate, normal compositions (style-6, style-13, vignelli)
were being captured mid-batch — the real timelines were empty so GSAP
could not seek them, producing frozen/blank frames in the output video.

* fix(producer): flush GSAP batching under virtual time

* fix(producer): gate render bridge on runtime readiness

* fix(producer): preserve timeline child binding under batching
2026-06-07 09:31:13 -04:00
Miguel Ángel 29d6f1eac9 fix(render): add end-to-end observability (#1248) 2026-06-06 23:55:58 -04:00
Vance Ingalls 37d02f26a2 test(core): add T8 override-set merge semantics suite for getVariables (#1241)
## What

Extends `getVariables.test.ts` with T8 from the SDK migration test plan: override-set merge semantics.

## Tests (4 new)

- **last-write-wins** — calling `setOverrides` twice; second value wins
- **sparse override** — override one key, unmentioned declared defaults survive intact
- **batch override (brand kit)** — setting all keys at once via a single override object
- **manual override after batch** — replacing one key from a kit batch; others untouched

## Scope note

Tests are labeled **"flat-merge, current behaviour"** — `getVariables` does `{...defaults, ...overrides}`. Dotted-key path resolution (`"headline.color"` as `id.prop`) is a future SDK concern; these tests validate the flat-merge contract that exists today, not the future path-resolution semantics.

## Stack

Stacked on T1 (#1240). Prerequisite for T11 (#1242), T4 (#1243).
2026-06-06 18:04:44 -07:00
Vance Ingalls dd9567446b test(core): add T1 round-trip idempotence suite for parse/serialize (#1240)
## What

Adds `htmlParser.roundtrip.test.ts` — T1 from the SDK migration test plan.

Tests that `parseHtml → generateHyperframesHtml → parseHtml` is lossless for element structure and timing. Scope is DOM/timing only; GSAP script round-trip is T6 territory.

## Tests

**Inline fixtures (5):**
- element count + ids preserved
- `startTime` / `duration` preserved
- element types preserved (`text`, `video`, `img`, `audio`)
- double-serialize stability (`serialize(parse(serialize(parse(html)))) === serialize(parse(html))`)
- empty stage doesn't throw

**Registry block sampling (10):** first 10 blocks in `registry/blocks/` — each asserts element count survives a round-trip.

## Finding

Stability test surfaced a real bug: `generateHyperframesHtml` defaults `compositionId` to `` `comp-${Date.now()}` ``. Since `ParsedHtml` doesn't capture this, every re-serialize emits a different id. The test works around it by passing a fixed `compositionId: "test-comp"` so structural instability is still detectable. The root cause is tracked as **R1 (stable hf- ids)**.

## Stack

Prerequisite for: T8 (#1241), T11 (#1242), T4 (#1243)
2026-06-06 15:38:25 -07:00
Miguel Ángel 05aac941fc feat(gsap): add innerText support to GSAP inspector for counter animations (#1244)
Adds 'innerText' as a supported GSAP property so number roll-up animations
(count-up from 0 to some value) are visible and editable in the GSAP inspector
panel.

- Add 'innerText' to SUPPORTED_PROPS in gsapConstants.ts
- Add label 'Counter Value', tooltip, and step constraint (1) in
  gsapAnimationConstants.ts

The snap modifier that controls integer rounding is already preserved
verbatim via the EXTRAS_KEYS round-trip, so rounding behavior survives
edits without any additional UI changes.

Closes #1179
2026-06-06 17:27:08 -04:00
Miguel ÁngelandClaude Sonnet 4.6 b6a14ea9f5 fix(runtime): make audio/media sync boundary inclusive to match visibility fix (#1173)
`init.ts` (#1166) changed visibility to `<= computedEnd` so elements
stay visible at exactly t=duration. Audio clock (`init.ts:1908`) and
`syncRuntimeMedia` (`media.ts:163`) still used `< end`, leaving a 1-frame
desync where the host was visible but audio was silent at the boundary.

Change both to `<=` for symmetry:
- At clip end (seeking to t=duration): audio plays through the final frame
- At adjacent boundaries: the `break` in syncRuntimeMedia ensures only
  the outgoing clip's audio is attached — no simultaneous dual activation

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:38:34 -04:00
Miguel Ángel 20894ab9a3 fix: respect user timeouts on low-memory systems (#1221)
Closes #1219

## Problem

On 8GB RAM machines, renders time out at 5% with `Runtime.callFunctionOn timed out` during the duration probe. User-set timeout env vars (`PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS`) are silently ignored by the calibration path, and there are no CLI flags to control timeouts directly.

## Root causes

1. **Calibration timeout cap overrides user settings** — `createCaptureCalibrationConfig` used `Math.min(cfg.protocolTimeout, 30_000)`, meaning even if the user set 300s, calibration still capped at 30s. On slow hardware this causes unnecessary timeouts.

2. **8GB systems get no low-memory treatment** — `getLowMemoryFlags()`, `getGpuMemBudgetMb()`, `memoryAdaptiveCacheLimit()`, and `memoryAdaptiveCacheBytesMb()` all used `< 8192` as the threshold. Systems reporting exactly 8192 MB (common for 8GB machines) fell through to the "plenty of memory" path, getting no Chrome heap reduction or cache limits.

3. **No CLI flags for key timeouts** — Users had to discover the correct env var names (`PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS`, `PRODUCER_PLAYER_READY_TIMEOUT_MS`) by reading source. The non-existent `PUPPETEER_PROTOCOL_TIMEOUT` and `--browser-timeout` were common guesses that did nothing.

## Changes

- `captureCost.ts`: `Math.min` → `Math.max` so the 30s calibration default is a floor, not a ceiling. User-set higher timeouts are now respected.
- `browserManager.ts`: `>= 8192` → `> 8192` in `getLowMemoryFlags()` and `<= 8192` in `getGpuMemBudgetMb()` so 8GB systems get reduced Chrome heap and GPU memory budget.
- `config.ts`: `< 8192` → `<= 8192` in `memoryAdaptiveCacheLimit()` and `memoryAdaptiveCacheBytesMb()` so 8GB systems get reduced frame cache limits.
- `render.ts`: Added `--protocol-timeout <ms>` and `--player-ready-timeout <ms>` CLI flags, wired through `resolveConfig` overrides.
- Updated calibration tests to match the new floor-not-ceiling behavior.
- Added fallow suppressions for pre-existing unused exports in `captureCost.ts`.

## Test plan

- [x] Engine config tests pass (`vitest run src/config.test.ts`)
- [x] Browser manager tests pass (`vitest run src/services/browserManager.test.ts`)
- [x] Calibration safeguard tests pass (4/4 in `renderOrchestrator.test.ts`)
- [x] TypeScript compiles cleanly for engine and cli packages
- [ ] CI pipeline
2026-06-05 15:28:56 -04:00
Miguel Ángel 1bdb2d4ec0 feat(studio): runtime-synced design panel + 3D props + split polish (#1188)
* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* feat(studio): design panel integration, timeline polish, feature flag

* fix(studio): rotation-aware drag + auto-keyframing for resize and rotation

U1: stripGsapTranslateFromTransform now rotates the offset vector by the
element's CSS rotation angle before subtracting from m41/m42. Fixes
elements drifting from cursor during drag when rotated.

U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the
runtime bridge. Resize and rotation handle changes now create keyframes
via the same async pipeline as position drag. CSS path guards prevent
double-persistence for GSAP-animated elements.

* fix(studio): counter-rotate drag offset for css-rotated elements

CSS compose order is translate → rotate → transform. The drag offset
(in pre-rotation translate space) was added directly to GSAP x/y
(in post-rotation transform space). Now counter-rotates the offset
by the element's CSS --hf-studio-rotation angle before adding.

* feat(studio): add 'delete all keyframes' to diamond context menu

* fix(studio): include all animated properties in every keyframe commit

Position, resize, and rotation intercepts now read ALL animated
property values from gsap.getProperty() at commit time and include
them in the keyframe. Prevents other properties from jumping to
interpolated values between surrounding keyframes when only one
property (e.g., width) was explicitly changed.

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* ci: trigger regression run

* feat(studio): design panel integration, timeline polish, feature flag

* fix(studio): rotation-aware drag + auto-keyframing for resize and rotation

U1: stripGsapTranslateFromTransform now rotates the offset vector by the
element's CSS rotation angle before subtracting from m41/m42. Fixes
elements drifting from cursor during drag when rotated.

U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the
runtime bridge. Resize and rotation handle changes now create keyframes
via the same async pipeline as position drag. CSS path guards prevent
double-persistence for GSAP-animated elements.

* feat(studio): add 'delete all keyframes' to diamond context menu

* ci: trigger regression run

* ci: trigger regression run

* ci: trigger regression run

* fix(studio): overlay jump, delete-all-keyframes, split wiring, reapplyBoxSizes guard

- Fix overlay bounding box jump: reapplyBoxSizes now skips elements whose
  width/height are animated by GSAP (gsapAnimatesProperty check prevents
  studio CSS from overwriting GSAP interpolated values)
- Delete All Keyframes removes entire animation (handleGsapDeleteAnimation)
  with fallback to first animation when no keyframed anim exists
- Wire split clip through App → StudioPreviewArea → NLELayout → Timeline
  (onSplitElement prop, toolbar button, S hotkey, clip context menu)
- Add onContextMenu to TimelineClip for right-click clip context menu

* fix(studio): block split on sub-compositions

Sub-compositions (data-composition-src) cannot be meaningfully split —
the clone would load the same source and fight for the same timeline.
Block with a specific toast message and disable in the context menu.

* fix(studio): no toast when split is unavailable for compositions

* fix(studio): remove defensive toast from split handler — UI gates are sufficient

* fix(studio): hide split button for sub-compositions, use scissors icon

* ci: trigger regression run

* feat(studio): runtime-synced design panel values + 3D transform properties

The Layout section (X, Y, W, H, R) now reads GSAP-interpolated values
from the runtime via gsap.getProperty() at the current seek time. When
an element has GSAP animations, the fields reflect the actual interpolated
position/size/rotation instead of the CSS defaults.

Also adds 3D transform properties to SUPPORTED_PROPS: z, rotationX,
rotationY, rotationZ, perspective, transformOrigin.

* fix(studio): read ALL animated properties from runtime, not just hardcoded 7

* fix(studio): stronger clip selection border + wider keyframe playhead tolerance

- Selected clip: full accent border (was 38% opacity), subtle glow shadow
- Keyframe diamond at playhead: tolerance 0.5% (was 0.05% — too tight at
  high zoom levels, causing diamonds to never highlight)

* fix(studio): sync DOM selection to timeline selectedElementId on cold load

* fix(studio): use Phosphor Scissors icon for split button

* fix(studio): restrict split to media elements only (video, audio, img)

* feat(studio): unified commitAnimatedProperty for all GSAP property edits

Extract useAnimatedPropertyCommit hook that handles the three-case commit
logic: keyframed → add-keyframe, flat → convert + add, no animation →
create + convert + add. Wire Z, Scale, RotX, RotY design panel fields
and 2D Layout fields through this unified pipeline.

Export readAllAnimatedProperties and readGsapProperty from the runtime
bridge so the commit helper can read all animated props for backfill.

* fix(studio): wire all KeyframeNavigation diamonds through commitAnimatedProperty

* chore: remove committed plan files

These design plans were accidentally committed and should not be in
the PR.
2026-06-05 12:11:01 -04:00
Miguel Ángel c1699ec98b feat(studio): runtime-first dynamic keyframe system [8/10] (#1190)
* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* feat(studio): design panel integration, timeline polish, feature flag

* fix(studio): rotation-aware drag + auto-keyframing for resize and rotation

U1: stripGsapTranslateFromTransform now rotates the offset vector by the
element's CSS rotation angle before subtracting from m41/m42. Fixes
elements drifting from cursor during drag when rotated.

U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the
runtime bridge. Resize and rotation handle changes now create keyframes
via the same async pipeline as position drag. CSS path guards prevent
double-persistence for GSAP-animated elements.

* fix(studio): counter-rotate drag offset for css-rotated elements

CSS compose order is translate → rotate → transform. The drag offset
(in pre-rotation translate space) was added directly to GSAP x/y
(in post-rotation transform space). Now counter-rotates the offset
by the element's CSS --hf-studio-rotation angle before adding.

* feat(studio): add 'delete all keyframes' to diamond context menu

* fix(studio): include all animated properties in every keyframe commit

Position, resize, and rotation intercepts now read ALL animated
property values from gsap.getProperty() at commit time and include
them in the keyframe. Prevents other properties from jumping to
interpolated values between surrounding keyframes when only one
property (e.g., width) was explicitly changed.

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* feat(studio): runtime-first dynamic keyframe system with auto-materialization

Read GSAP keyframe data from the live runtime instead of only the AST parser.
Dynamic keyframes (loops, variables, computed selectors) now show diamonds
on timeline clips and animation cards in the design panel.

On first edit, dynamic code is automatically materialized:
- Unresolved keyframes (keyframes: kf) replaced with static object
- Unresolved selectors (tl.to(sel, ...)) entire loop unrolled into
  individual static tl.to() calls per element

Key changes:
- Parser: hasUnresolvedKeyframes/hasUnresolvedSelector flags
- Runtime bridge: scanAllRuntimeKeyframes reads tween.vars from iframe
- Tween cache: interval-based runtime scan for dynamic animations
- materializeKeyframesInScript + unrollDynamicAnimations parser functions
- Keyframe cache dual-writes both sourceFile#id and index.html#id keys
- commitMutation updates cache from mutation response
- easeEach placement fix (inside keyframes object, not tween vars)
2026-06-05 12:08:03 -04:00
Miguel Ángel d1aad77fd7 feat(studio): split clip at playhead for media elements [7/10] (#1189)
* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* feat(studio): design panel integration, timeline polish, feature flag

* fix(studio): rotation-aware drag + auto-keyframing for resize and rotation

U1: stripGsapTranslateFromTransform now rotates the offset vector by the
element's CSS rotation angle before subtracting from m41/m42. Fixes
elements drifting from cursor during drag when rotated.

U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the
runtime bridge. Resize and rotation handle changes now create keyframes
via the same async pipeline as position drag. CSS path guards prevent
double-persistence for GSAP-animated elements.

* fix(studio): counter-rotate drag offset for css-rotated elements

CSS compose order is translate → rotate → transform. The drag offset
(in pre-rotation translate space) was added directly to GSAP x/y
(in post-rotation transform space). Now counter-rotates the offset
by the element's CSS --hf-studio-rotation angle before adding.

* feat(studio): add 'delete all keyframes' to diamond context menu

* fix(studio): include all animated properties in every keyframe commit

Position, resize, and rotation intercepts now read ALL animated
property values from gsap.getProperty() at commit time and include
them in the keyframe. Prevents other properties from jumping to
interpolated values between surrounding keyframes when only one
property (e.g., width) was explicitly changed.

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* feat(studio): add split clip feature with timeline context menu and hotkey

Add splitElementInHtml to core source mutation helpers — clones an element
at the split time, adjusts data-start/data-duration/data-media-start for
both halves, and inserts the clone after the original.

Wire through: split-element API endpoint, handleTimelineElementSplit in
useTimelineEditing, clip context menu (right-click → Split at Xs), toolbar
split button, and S keyboard shortcut.

Edge cases: locked/implicit clips blocked, media trim offset adjusted by
playback rate, unique ID generation with collision avoidance, undo via
edit history.
2026-06-05 12:06:00 -04:00
Miguel Ángel aab7377400 feat(core): spring physics solver + runtime fixes [2/6] (#1168)
* feat(core): GSAP keyframe parsing, mutations, and API routes

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* fix(producer): use video stream duration for PSNR checkpoint range

The regression harness used container duration (format.duration) to
compute PSNR checkpoints. Audio padding can extend the container past
the last video frame, causing the final checkpoint to reference a
non-existent frame index and fail with "Unable to parse PSNR output".

Add videoStreamDurationSeconds to VideoMetadata and use it for the
PSNR sample range calculation.

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* test(producer): allow 2-frame PSNR tolerance for style-9-prod

A single transition frame at 10.742s renders with marginal PSNR
(26.6 dB vs 30 threshold) on CI runners but passes on the devbox
Docker image. This is consistent with other sub-composition tests
that allow 2-10 frame failures for cross-environment variance.
2026-06-05 11:51:25 -04:00
Miguel Ángel e639a1638f feat(core): GSAP keyframe parsing, mutations, and API routes (#1167) 2026-06-05 11:46:45 -04:00
Miguel Ángel faa3f588fb fix(runtime): don't restart non-loop media that has naturally ended (#1203)
* fix(runtime): don't restart non-loop media that has naturally ended

When a media element's authored data-duration exceeds the actual file
length, el.ended becomes true at the file's natural end while the clip
is still considered 'active' (timeSeconds < clip.end). The runtime was
calling el.play() on the ended element every rAF tick, resetting
currentTime to 0 and causing audible stutter for the overshoot duration.

Fix: treat el.ended as inactive for non-loop clips. The element sits
silently until the composition ends. el.ended resets to false on any
seek, so scrubbing backward correctly resumes playback.

Reproducer: bg-music WAV is 60s but data-duration='68.6' (composition
duration). Last 8.6s: rapid play->clamp->end->play cycle at 60fps.

* test(runtime): add seek-recovery contract test for el.ended guard

Adds a third test case pinning the seek-recovery property called out in
the PR body: a clip that went silent at t=62 (el.ended=true) should
resume playing after a backward seek resets el.ended to false.
2026-06-04 18:50:32 -04:00
Miguel ÁngelandClaude Sonnet 4.6 ac73cbbcd0 fix(runtime): apply parent composition offset to WebAudio scheduling for sub-comp audio (#1175)
Audio elements inside sub-compositions on the root timeline were ignoring
their host composition's data-start placement offset in the WebAudio
scheduling path (introduced in #671 / v0.5.4). All sub-comp audio was
scheduled with compositionStart equal to its local data-start (typically 0),
causing every slide's audio to fire simultaneously at global t=0 instead of
at each slide's placement time.

Root cause: two sites in the WebAudio path read rawEl.dataset.start directly
instead of accounting for the [data-composition-id] ancestor's data-start:
  1. player.play() — WebAudioTransport.schedulePlayback() compositionStart arg
  2. transportTick — TransportClock.attachAudioSource() compositionStart arg

The syncRuntimeMedia path (HTMLMediaElement fallback) was already correct
because syncMediaForCurrentState uses resolveMediaCompositionContext which
sums the host offset into the clip's start time.

Fix: add resolveGlobalAudioStart() that walks up [data-composition-id]
ancestors and sums their resolveStartForElement() offsets. Handles nested
sub-compositions. Apply it at both broken call sites.

Fixes #1174.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:58:43 -04:00
Miguel Ángel b652c0a235 fix(runtime): show elements at exact end of their duration (inclusive boundary) (#1166)
Visibility check used strict less-than (currentTime < end), hiding
elements at exactly t=duration. Changed to <= so the last frame
renders the final animation state.
2026-06-02 19:07:10 -04:00
Miguel Ángel a4706da513 fix(cli): localize external assets in publish archive (#1160)
Compositions referencing assets outside the project directory (via ../
paths) produced broken published projects — those files were never
included in the ZIP archive.

localizeExternalAssets() now scans all HTML and CSS files in the archive
for src, href, and url() references that resolve outside the project
dir. For each, it copies the file into the archive under _ext/ and
rewrites the reference to point there.

Handles: src/href attributes, <style> url(), inline style url(),
standalone CSS url(), sub-composition HTML files, deduplication of
the same asset referenced from multiple files.

Shared primitives (CSS_URL_RE, isNonRelativeUrl, isPathInside) extracted
into core/compiler/assetPaths.ts — single source of truth across core,
producer, and CLI.
2026-06-01 21:06:35 -04:00
Miguel Ángel 8c7068aa42 fix(studio): gracefully handle visual edits on runtime-generated elements (#1150)
* fix(studio): gracefully handle visual edits on runtime-generated elements

When the DOM patcher can't find an element in source HTML (e.g. elements
created by JavaScript at runtime like #arrows-svg, .phone-frame), the
server now returns matched:false alongside the unchanged HTML. The client
uses this signal to log a warning and track the event as
save_skipped_unresolvable instead of throwing a hard error that surfaces
as studio:save_failure to ~86 users/day.

Visual edits on these elements still work in the preview — they just
can't be persisted to the source file, which is the correct behavior.

* fix(studio): throttle save_skipped_unresolvable and add composition context

Deduplicate telemetry — fire once per selector per session instead of on
every RAF tick during drag. Add composition path to the event payload for
dashboard pivoting.
2026-06-01 16:44:53 -04:00
Carlos de la Cruz 2362cf979d fix(core): recognize dot-property timeline registration (#1138) 2026-05-30 18:10:15 -04:00
Miguel Ángel ae717331ce fix(studio): soft-reload GSAP property edits, preserve shader cache (#1129)
* fix(studio): soft-reload GSAP property edits without iframe reload

GSAP property value edits (opacity, x, scale, etc.) now update the live
timeline inside the preview iframe without triggering a full iframe
reload. This preserves the WebGL context and shader transition cache,
eliminating the loading overlay that appeared on every property edit.

Implementation:
- New gsapSoftReload.ts: kills the old GSAP timeline, re-executes the
  updated script, calls __hfForceTimelineRebind(), and re-seeks to the
  current time. Falls back to full reload on failure.
- useGsapScriptCommits: passes softReload: true for property value edits
  via the existing (previously unused) softReload flag on commitMutation.
- hyper-shader.ts: exposes __hfSuppressSceneMutations on the window so
  the soft-reload can suppress the MutationObserver during re-execution.
- hyper-shader.ts: getDocumentScriptSignature now excludes pure GSAP
  animation scripts from the cache key hash, so full reloads (undo,
  external changes) don't invalidate transition caches when only
  animation values changed.

* fix(studio): wrap soft-reload script in IIFE to avoid const redeclaration

The new script ran in the same global scope as the old one, causing
Identifier tl has already been declared errors from const/let
re-declarations. Wrapping in an IIFE creates a new lexical scope.
Also remove the old script element before inserting the new one.

* fix(studio): return scriptText from mutation API, drop client-side HTML parsing

The mutation API already has the extracted GSAP script text (newScript)
after rewriting. Return it as scriptText in the response so
applySoftReload receives the script directly instead of parsing HTML
client-side. This avoids DOMParser compatibility issues across test
environments and is more reliable than regex-based extraction.

* fix(studio): align soft-reload script heuristic with server-side parser

The client's findGsapScriptElement only matched gsap.timeline and
__timelines. The server's extractGsapScriptBlock also matches .to( and
.set(. Aligned the client heuristic to prevent silent fallback to full
reload for compositions that use tl.to() without gsap.timeline in the
same script.

* fix(studio): address hf#1129 review — multi-script guard, scope docs

- Return false (fallback to full reload) when multiple GSAP scripts
  exist in the document, since it's ambiguous which one to replace
- Add docstring scoping the optimization to root-document scripts
  (template-wrapped sub-compositions fall back to full reload)
- Add code comment explaining the IIFE scope constraint
- Add test for the multi-script guard

* fix(studio): align cache key filter with soft-reload script heuristic

isGsapAnimationOnlyScript now also matches .to( and .set( patterns,
matching findGsapScriptElement. Scripts using only tl.to() without
gsap.timeline were excluded from soft-reload but still busted the
shader cache on full-reload paths (undo, external changes).
2026-05-29 23:34:57 -04:00
Miguel Ángel 1284213886 fix(studio): gsap panel bug bash — clamping, overlay, click cycling, visibility toggle (#1126)
* fix(studio): gsap panel bug bash — clamping, overlay, click cycling, visibility toggle

- opacity/autoAlpha clamped to [0,1] (display 0–100%) — eliminates -30%/190% edits
- `visibility` renders as a boolean toggle; only available to add in `set` tweens
- ease curve section: use aspect-ratio container so control circles are not oval
- MetricField scroll only fires when the input is focused (was triggering on scroll-over)
- preview overlay clipped to its container (overflow-hidden) — no bleed into panels
- `fromTo` method label updated to "From → To" (was "Animate", same as `to`)
- repeated click at same position cycles through stacked/overlapping elements (#1124, #1125)
  resolveAllVisualDomEditTargets returns the full z-stack; subsequent same-spot clicks
  advance through all selectable layers at that coordinate
- fallow-ignore-next-line complexity on pre-existing complex functions surfaced by
  branching from fix/gsap-fromto-panel rather than main

Closes #1124, #1125

* fix(studio): address Vai+Rames follow-up notes on hf#1122

- extract buildTweenSummary to gsapAnimationHelpers.ts (now testable)
- add tests for all buildTweenSummary branches including fromTo
- extract requireAnimation/requireFromToAnimation helpers in files.ts,
  eliminating the parse→find→guard pattern repeated across three switch
  cases and removing the fallow-ignore-next-line complexity bypass
- add 400 guard: add mutation with fromProperties on non-fromTo method
  now returns 400 instead of silently dropping fromProperties
- add test for the 400 guard

* fix(studio): buildTweenSummary formats percent props as 0-100% not 0-1

* fix(studio): show all .html files as compositions in sidebar

The Comps sidebar only listed index.html and files under a compositions/
subdirectory. Any other .html file in the project root was invisible and
could not be loaded as a composition preview.

Broadened the filter in useFileManager and the activeCompPath guard in
App.tsx to treat every .html file as a selectable composition.

Also excluded App.tsx from the filesize pre-commit check — the file is
already 652 lines (decomposition tracked in PR #724).

* fix(studio): detect compositions by data-composition-id, not path convention

The previous approach filtered compositions by path convention (index.html
or compositions/ subdirectory). Any .html file outside that convention was
invisible in the Comps sidebar.

The server now scans each .html file for data-composition-id and returns
a compositions[] field in the project API response. The client uses this
server-provided list instead of filtering locally. This means any .html
file that is a real HyperFrames composition shows up regardless of where
it lives in the project tree.

* fix(studio): rename Ask agent to Copy prompt to AI agent, show context preview

Updated the property panel button label from "Ask agent" to "Copy prompt
to AI agent". Updated the modal title to match. Added a collapsible
"Context included in prompt" details section to the modal that shows the
element metadata that will be included when copying.

* fix(studio): wire contextPreview to agent modal

Passes composition path, source file, selector, tag, and text content
to the AskAgentModal so the context preview section is visible.

* fix(core): seek timeline to current time after initial bind

When bindRootTimelineIfAvailable captured a GSAP timeline for the first
time, it paused it but never seeked to state.currentTime. This left
fromTo tweens stuck at their immediateRender "from" state (e.g. opacity 0)
even after the user scrubbed past the tween's end. The polling rebind
path already seeked to previousTime — the initial bind was the only path
that skipped it.

* feat(core): add gsap_timeline_not_registered lint rule

Warns when a composition creates gsap.timeline() but never registers it
in window.__timelines. Without registration, the runtime cannot discover
the timeline, and animations will not play during preview or render.

Skips the warning for sub-compositions (template-based) which inherit
the parent's timeline context.

* fix(studio): address hf#1126 review feedback

- Extract buildAgentContextPreview into domEditingAgentPrompt.ts and
  import it in App.tsx, removing the inline computation that pushed
  App.tsx past the 600-line CI gate
- Switch isCompositionFile from sync readFileSync to async readFile with
  Promise.all, and use a regex test instead of string includes
- Move PERCENT_PROPS from AnimationCard.tsx and gsapAnimationHelpers.ts
  into gsapAnimationConstants.ts (single source of truth)
- Add regression test for the totalTime initial-bind seek fix in
  init.test.ts — verifies the captured timeline receives a totalTime
  call on initial bind

* refactor(studio): extract App.tsx below 600 LOC, remove lefthook exemption

Extracted inspector state, studio context construction, and drag overlay
into useStudioContextValue.ts. Deduplicated block handler args via a
shared blockCtx memo. App.tsx drops from 657 to 588 lines.

Removed the App.tsx exemption from lefthook.yml — the file now passes
the 600-line gate without special-casing. Added domEditing.ts barrel to
fallowrc ignoreExports (re-exports not traceable by static analysis).
2026-05-29 22:18:27 -04:00