Commit Graph
23 Commits
Author SHA1 Message Date
terencechoandClaude Sonnet 4.6 dd375e2784 fix(player): clamp scrubber progress when postMessage frame exceeds duration (#700)
The postMessage state path set `_currentTime` without clamping, while the
direct timeline path already used `Math.min(currentTime, _duration)`. A
final-frame state message with a frame count slightly past the end would
set `_currentTime > _duration`, causing the progress bar (position:
absolute, no overflow guard) to bleed out of the scrubber track and
visually cover the volume button, and the time display to show values
like "0:05 / 0:04".

- Clamp `_currentTime` in `_onMessage` to match the direct timeline path
- Clamp defensively in `updateTime` so the display layer never overflows
- Add `overflow: hidden` + `min-width: 0` to `.hfp-scrubber` as a CSS
  safety net; remove now-redundant `border-radius` from `.hfp-progress`
  (parent `overflow: hidden` handles clipping to the rounded shape)
- Apply the same `overflow: hidden` fix to `.hfp-volume-slider` for
  consistency; remove redundant `border-radius` from `.hfp-volume-fill`
- Add regression test covering the postMessage over-duration case

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:23:19 -07:00
Miguel Ángel f761ee2624 fix(player): drive src URL timelines without runtime (#673)
* fix(player): drive src URL timelines without runtime

* fix(player): pause direct timeline after seek
2026-05-08 08:19:29 +02:00
Miguel ÁngelandClaude Opus 4.6 a7a6648852 feat(player): add volume/mute controls (#651)
* feat(player): add volume/mute controls to the player

Adds a mute toggle button and volume slider to the controls bar,
positioned between the time display and speed selector. The slider
expands on hover for a compact default footprint.

- `volume` attribute/property (0–1, clamped) with `volumechange` event
- `muted` attribute now syncs to the controls UI (icon updates)
- Three volume icons: high, low, muted — updates reactively
- Volume forwarded to parent-frame audio proxies and iframe runtime
  via `set-volume` postMessage control
- 9 new tests covering volume clamping, events, controls rendering,
  mute toggle, and iframe message forwarding

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

* fix(player): wire set-volume through runtime bridge + address review feedback

Addresses the blocker from PR review: the iframe runtime now handles
`set-volume` messages end-to-end (types → bridge → init → media sync).

Runtime side:
- Add `set-volume` to RuntimeBridgeControlAction union
- Add `volume` field to RuntimeBridgeControlMessage
- Handle `set-volume` in bridge.ts with [0,1] clamping
- Store bridgeVolume in RuntimeState, apply to media elements
- syncRuntimeMedia composes userVolume × clip author volume

Player side:
- Muted toggle now dispatches `volumechange` (HTML5 spec compliance)
- Volume slider auto-unmutes when scrubbed above 0 while muted
- Touch support on volume slider (touchstart/move/end)

Tests: 5 new (3 bridge, 2 media)

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

* fix(player): add ARIA keyboard controls, fix icon collision and clipVolume parity

- Volume slider: role="slider", aria-label, aria-valuemin/max/now,
  tabindex=0, arrow key support (5% steps, auto-unmutes)
- Volume=0 unmuted now shows low-volume icon instead of muted icon
- Fix clipVolume divergence: init.ts uses Number.isFinite() matching
  media.ts semantics (preserves data-volume="0")
- 3 new tests: ARIA attributes, volumechange on mute, icon collision

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 00:38:27 +02:00
Miguel Ángel 632731b076 fix(player): replay from start when play is pressed after video ends (#649)
* fix(player): replay from start when play is pressed after video ends

When a non-looping composition reaches its end, pressing play again
had no effect because the playhead stayed at the final frame. Now
play() detects the ended state and seeks to 0 before resuming.

* chore: fix pre-existing format issues in registry files
2026-05-06 20:44:48 +02:00
Vance Ingalls a7b308b667 feat: cache shader transition preview frames (#634)
* feat: cache shader transition preview frames

* fix: move shader transition loading to player
2026-05-06 01:25:09 -07:00
Miguel Ángel 7affa4a4e9 fix: handle player loop and render exit (#617)
## Problem

Two newly reported runtime issues break common local workflows:

- Fixes #615: `<hyperframes-player loop>` reaches the final frame, receives a paused runtime state, and stays paused instead of wrapping.
- Fixes #616: `hyperframes render` can finish writing the output and print `Render complete`, but still remain alive when a non-essential handle keeps Node's event loop open.

The catalog block also used old VPN branding and slug/file names that should now be neutral. Renaming registry items also exposed a catalog-preview CI bug where deleted registry paths were treated as still-renderable changed items.

## What this fixes

- detects player completion from the previous playing state before mutating the parent `_paused` cache from the runtime's final state
- wraps looping players back to `0` and immediately resumes playback even when the runtime posts `isPlaying: false` at the end frame
- keeps non-looping players dispatching the existing `ended` flow
- lets the CLI command path schedule a short unref'd `process.exit(0)` after a successful local or Docker render
- keeps `renderLocal()` importable for tests and internal callers without forcing process exit unless the CLI command explicitly opts in
- adds regression coverage for the player loop end-state and successful render exit scheduling
- renames the VPN catalog block to `vpn-youtube-spot` across registry, docs route, install command, composition filename, asset filename, composition id, and timeline key
- keeps visible block/app copy friendly and named `VPN`
- updates catalog-preview CI to ignore deleted registry paths when computing changed preview items

## Root cause

The player message handler updated `_paused = !data.isPlaying` before checking for end-of-composition loop behavior. The runtime's legitimate final-frame state has `isPlaying: false`, so the existing `currentTime >= duration && !paused` loop branch was skipped.

For render completion, the CLI returned after `printRenderComplete()`, leaving process lifetime entirely to Node's active handles. Most local renders in this checkout drain cleanly, but the reported npm flow shows a sleeping parent process after output is already complete. The CLI now schedules a short unref'd successful exit only from the command path after user-visible render work has completed.

The catalog block issue was content/metadata drift: registry/docs/code identifiers still used the old slug, so the catalog route, install command, composition id, file names, and source prompt did not match the requested neutral VPN naming. The preview workflow used plain `git diff --name-only`, which includes deleted paths during renames; it now filters to added/copied/modified/renamed live paths.

## Verification

### Local checks

- `bun run build:hyperframes-runtime`
- `bun run --filter @hyperframes/player test -- src/hyperframes-player.test.ts`
- `bun run --filter @hyperframes/cli test -- src/commands/render.test.ts`
- `bun run --filter @hyperframes/player typecheck`
- `bun run --filter @hyperframes/cli typecheck`
- `bunx oxfmt --check packages/player/src/hyperframes-player.ts packages/player/src/hyperframes-player.test.ts packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts`
- `bunx oxlint packages/player/src/hyperframes-player.ts packages/player/src/hyperframes-player.test.ts packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts`
- `bun run --filter @hyperframes/player build`
- `bun run --filter @hyperframes/studio build`
- `bun run --filter @hyperframes/cli build`
- `bunx oxfmt --check registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html registry/blocks/vpn-youtube-spot/registry-item.json registry/registry.json docs/catalog/blocks/vpn-youtube-spot.mdx docs/docs.json docs/public/catalog-index.json`
- `bunx oxlint registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html registry/blocks/vpn-youtube-spot/registry-item.json registry/registry.json docs/catalog/blocks/vpn-youtube-spot.mdx docs/docs.json docs/public/catalog-index.json`
- `bunx oxfmt --check .github/workflows/catalog-previews.yml`
- `BASE_SHA=26b8e2a9853eb1a8f77c05fb0c8f0903cdb2cf18; git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD -- registry/blocks/ registry/components/ ...` returns only `vpn-youtube-spot`
- `npx tsx scripts/sync-schemas.ts --check`
- `npx mint validate` from `docs/`
- `npx mint broken-links` from `docs/`
- `git diff --check`
- Lefthook pre-commit: format pass
- Lefthook commit-msg: commitlint pass

### Browser verification

- Built the player bundle and served a real local reproduction using the built player, the built HyperFrames runtime, and GSAP.
- Used `agent-browser` to open the page, click `Seek near end`, and wait through the end-frame transition.
- Verified the browser state after playback: `stuck=false`, `looped=true`, and playback continued after wrapping from ~4s back to the start.
- Served `registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html` locally, used `agent-browser` to seek the timeline, and verified `window.__timelines` contains `vpn-youtube-spot`, not `goonvpn-youtube-spot`.
- Served the docs locally with Mintlify, opened `/catalog/blocks/vpn-youtube-spot`, and verified the install command is `npx hyperframes add vpn-youtube-spot` with no old slug visible.

### Composition verification

- `bun run --filter @hyperframes/cli dev lint /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if` returned 0 errors and 1 existing large-composition warning.
- `bun run --filter @hyperframes/cli dev validate /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if --timeout 5000` returned 0 console errors; it reported existing non-fatal contrast audit warnings from the block styling.
- `bun run --filter @hyperframes/cli dev render /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if --output /tmp/hf-vpn-renamed-proof.mp4 --fps 30 --quality draft --workers 1 --no-browser-gpu` completed successfully.
- `ffprobe -v error -show_entries format=duration,size -of default=noprint_wrappers=1 /tmp/hf-vpn-renamed-proof.mp4` reported `duration=7.000000`.

### Render verification

- Ran a real 1920x1080, 5-second render with `--gpu --workers 6 --quality draft --fps 24`.
- Verified the command printed `Render complete` and the parent process exited with code `0` in the wrapper: `RENDER_EXIT_PROOF code=0 signal=null sawComplete=true`.

## Notes

- I could not reproduce the exact indefinite #616 render hang on this checkout; both tiny and GPU/6-worker local renders exited cleanly before and after the patch. The CLI guard still addresses the reported leaked-handle failure mode because it fires only after successful render completion.
- Browser proof artifacts were local-only: `/tmp/hf-player-loop-proof-final.png`, `/tmp/hf-player-loop-proof-final.webm`, `/tmp/hf-vpn-code-rename-proof.png`, `/tmp/hf-vpn-code-rename-proof.webm`, `/tmp/hf-vpn-doc-route-rename-proof.png`, and `/tmp/hf-vpn-doc-route-rename-proof.webm`.
- The renamed composition render artifact was local-only: `/tmp/hf-vpn-renamed-proof.mp4`.
- The CLI exit guard is only enabled by the `render` command's top-level local/Docker calls. Direct test/internal calls to `renderLocal()` do not force process exit unless they pass `exitAfterComplete: true`.
2026-05-04 23:31:30 +02:00
Vance Ingalls 150d9348bc perf(player): srcdoc composition switching for studio (#398)
## Summary

Adds `srcdoc` support to `<hyperframes-player>` and uses it from studio's `Player.tsx` so composition switches no longer trigger an iframe navigation. Studio fetches the composition HTML on the parent and hands it to the iframe inline; the browser skips the navigation request, preconnect/handshake, and a redundant cache lookup.

## Why

Step `P3-2` of the player perf proposal. Profiling studio's project switcher showed that ~30–80 ms of every composition swap was spent in the iframe's own navigation pipeline — DNS / TCP / TLS reuse checks, request hand-off to the network process, and the second cache lookup against the same origin we just fetched from. For same-origin previews (`/api/projects/.../preview`) this is pure overhead: the parent already has the bytes (or can pull them from its own HTTP cache).

`srcdoc` lets us skip that pipeline entirely. The iframe loads from an in-memory string and the parent's `fetch` reuses any existing response from the page's HTTP cache, so the second-and-Nth composition switch in a session is essentially free at the network layer.

## What changed

### `<hyperframes-player>` (`packages/player/src/hyperframes-player.ts`)

- Added `srcdoc` to `observedAttributes` so runtime swaps actually fire `attributeChangedCallback`.
- On connect, both `srcdoc` and `src` are forwarded to the inner iframe — no manual precedence; the HTML spec already says `srcdoc` wins when both are present, so the browser handles arbitration.
- New `srcdoc` branch in `attributeChangedCallback`:
  - Resets `_ready = false` on every change so the next iframe `load` event re-runs probe/control/poster setup against the fresh document.
  - Distinguishes `setAttribute("srcdoc", "")` (deliberate empty document) from `removeAttribute("srcdoc")` (fall back to `src`) — the former propagates an empty-string srcdoc; the latter strips the attribute so a previously-set `src` can take over.

### Studio `Player.tsx` (`packages/studio/src/player/components/Player.tsx`)

- Hoisted `AbortController` and resolved `url` outside the dynamic-import `.then()` so the cleanup function can cancel an in-flight composition fetch when the user navigates away mid-load.
- After the player module loads, `fetch(url, { signal })` pulls the composition HTML on the parent.
  - Success → `player.setAttribute("srcdoc", html)`.
  - Network error / non-2xx → fall back to `player.setAttribute("src", url)`. Same code path the player has always taken, so this optimization is strictly a win — never a regression.
  - `AbortError` → bail without touching the DOM (component is unmounting).
- Attributes are set **before** `appendChild` so the iframe never loads an intermediate `about:blank`. That matters because:
  1. The first iframe `load` event must fire for the real composition; the existing handler treats `loadCountRef > 1` as a hot-reload and replays the reveal animation. An extra `about:blank` load would trigger the reveal on initial mount.
  2. `useTimelinePlayer` hangs setup off the first load — running it against an empty document is wasted work.

## Test plan

- [x] 7 new unit tests in `hyperframes-player.test.ts` covering:
  - `srcdoc` is in `observedAttributes`.
  - Initial `srcdoc` set before connect forwards to the iframe on connect.
  - Runtime `srcdoc` set after connect forwards via `attributeChangedCallback`.
  - `_ready` resets when `srcdoc` changes so `onIframeLoad` replays setup.
  - `removeAttribute("srcdoc")` strips the attribute on the iframe so `src` can take over.
  - Empty-string `srcdoc` is preserved (not treated as removal).
  - Both `src` and `srcdoc` set together: both get forwarded to the iframe and the browser arbitrates per spec.
- [x] Studio fallback path verified manually — disabling fetch falls back to the original `src` flow with no regression.

## Stack

Step `P3-2` of the player perf proposal. Builds on `P3-1` (sync seek) — both target the studio editor's interactive feel. With sync seek removing scrub latency and `srcdoc` removing composition-switch latency, the editor's two most-frequent interactions both shed their iframe-navigation overhead.
2026-04-22 17:59:01 -07:00
Vance Ingalls ef3de5bcd3 feat(player): synchronous seek() API with same-origin detection (#397)
## Summary

Formalizes the same-origin shortcut Studio has been using privately (`iframe.contentWindow.__player.seek` in `useTimelinePlayer.ts`) as a first-class behavior of `<hyperframes-player>`'s public `seek()` method. Same-origin seeks now land in the same task as the input event — no postMessage hop, no extra microtask, no perceived scrub lag. Cross-origin embeds fall through to the existing async bridge transparently.

## Why

Step `P3-1` of the player perf proposal. The current `seek()` always posts a message to the iframe runtime, which means a single user scrub incurs:

1. JS task: fire postMessage from parent
2. Browser task switch into iframe context
3. Microtask: handler dispatches
4. Frame: runtime calls `markExplicitSeek` and updates DOM

Same-origin embeds (Studio, preview pane, embedded compositions) can skip all four by calling the runtime's `seek` directly. Studio was already doing this manually but had to duplicate the local-state bookkeeping (`_currentTime`, `paused`, controls UI) — making it a first-class behavior of the player removes the workaround and gives every same-origin consumer the win for free.

## What changed

- New `_trySyncSeek(time)` helper attempts a synchronous call into the iframe's `window.__player.seek`. Returns `true` on success, `false` on cross-origin or pre-bootstrap.
- `seek()` calls `_trySyncSeek` first, falls through to the existing `_sendControl` postMessage path when sync isn't available.
- Detection is a `try/catch` on `contentWindow` access (real cross-origin iframes throw `SecurityError`) plus a `typeof` guard on `__player.seek`.
- Local `_currentTime`, the `paused` flag, and the controls UI update on both paths so scrubs never leave stale state.
- Runtime-side `seek` is the same wrapped function the postMessage handler calls — `installRuntimeControlBridge` routes through `player.seek`, so `markExplicitSeek()` and downstream runtime state are identical between the two paths.

## Test plan

- [x] 11 new unit tests in `hyperframes-player.test.ts` covering:
  - Same-origin sync path executes `__player.seek` synchronously and skips postMessage.
  - Cross-origin (simulated `SecurityError` on `contentWindow`) falls back to postMessage.
  - Pre-bootstrap (no `__player` installed) falls back to postMessage.
  - `__player.seek` not a function falls back to postMessage.
  - `_currentTime`, `paused`, and controls all stay in sync on both paths.
  - Errors thrown from `__player.seek` propagate without corrupting state.

## Stack

Step `P3-1` of the player perf proposal. Independent of the `P1-*` work — this is a pure latency win on the seek/scrub path. Combined with `P3-2` (srcdoc composition switching, next in the stack) it removes most of the iframe-bridge overhead from the studio scrubber.
2026-04-22 17:50:23 -07:00
Vance Ingalls f906797222 perf(player): coalesce _mirrorParentMediaTime writes (#396)
## Summary

Coalesce writes to `el.currentTime` inside `_mirrorParentMediaTime` so a single jitter sample no longer triggers a parent-media seek. A drift correction now requires **two consecutive samples** above the threshold (~`MIRROR_DRIFT_THRESHOLD_SECONDS`) before the player writes back. One-shot alignment paths (`promoteToParentProxy`, `_onIframeMediaAdded`) opt out via `force: true` so initial alignment stays immediate.

## Why

Step `P1-4` of the player perf proposal. `_mirrorParentMediaTime` is called every animation frame on parent media proxies. Even without true drift, browser internals report tiny jitter on `currentTime` reads — typically below 30 ms but occasionally crossing the threshold for a frame. Writing to `currentTime` triggers a seek, which is expensive *and* invalidates pipeline buffers, which causes the next frame's reading to jitter further. The result was unnecessary seek thrash on otherwise-aligned media.

By requiring two consecutive over-threshold samples, transient jitter is filtered out while real drift (a sustained offset) still corrects within ~1 frame of latency. This eliminates the most common cause of dropped frames on the studio thumbnail grid.

## What changed

- Each `_parentMedia` entry gains a `driftSamples` counter that increments while the absolute drift is above `MIRROR_DRIFT_THRESHOLD_SECONDS` and resets to 0 on the first sample below.
- `_mirrorParentMediaTime(el, opts)` only writes back when `driftSamples >= 2`, except when `opts.force === true`.
- `promoteToParentProxy` and `_onIframeMediaAdded` pass `force: true` so the first alignment after registration is still immediate (these are user-visible state transitions, not steady-state telemetry).

## Test plan

- [x] 11 new unit/integration tests in `hyperframes-player.test.ts` covering:
  - Single-sample jitter does not trigger a write.
  - Two-sample sustained drift does trigger a write.
  - Trending drift correction (gradually increasing offset) is detected within 2 samples.
  - `force: true` override bypasses the sample requirement.
  - Out-of-range proxies (proxies whose source has been removed) do not panic.
  - Multiple proxies maintain independent counters — drift on one does not affect the other.
  - `_promoteToParentProxy` alignment is immediate.

## Stack

Step `P1-4` of the player perf proposal. Builds on `P1-1` (shared adopted stylesheets) and `P1-2` (scoped media observer). Together these three target the studio multi-player render path — `P0-1*` perf gate scenarios will pick up the wins automatically.
2026-04-22 17:44:49 -07:00
Vance Ingalls a6e14da45c perf(player): scope MutationObserver to composition hosts (#395)
## Summary

Replace the body-wide `MutationObserver` in `<hyperframes-player>` with one scoped to top-level `[data-composition-id]` hosts. The wide observer fired on every body-level mutation — analytics scripts, runtime telemetry markers, dev overlays — even though only composition subtrees can introduce new timed media (`<audio data-start>`, etc.).

## Why

Step `P1-2` of the player perf proposal. The previous implementation observed `iframe.contentDocument.body` with `subtree: true` to pick up sub-composition `<audio data-start>` elements added after initial mount. That worked, but it was paying for callbacks from every unrelated DOM mutation in the iframe — most of which are just runtime instrumentation. Hot paths in the studio (timeline updates, telemetry markers) end up triggering the observer dozens of times per frame.

Scoping to composition hosts cuts the noise by ~10× in the studio without losing any of the timed-media wiring guarantees.

## What changed

- New `selectMediaObserverTargets(doc)` helper in `packages/player/src/mediaObserverScope.ts` that selects all top-level `[data-composition-id]` elements **excluding** nested ones — sub-composition hosts whose media is already covered by the parent observer's `subtree: true`.
- The player now attaches a single `MutationObserver` instance per top-level host (`subtree: true`), so callbacks still batch across hosts but skip out-of-host noise.
- Falls back to observing `body` when no composition hosts exist (e.g. blank iframe between `src` changes) — preserves prior behavior for non-composition documents and avoids breaking the bootstrap path.

## Test plan

- [x] 8 new unit tests in `mediaObserverScope.test.ts` covering empty docs, single host, multiple hosts, nested-host filtering, and the body-fallback path.
- [x] 2 new integration tests in `hyperframes-player.test.ts` spying on `MutationObserver.prototype.observe` to confirm the targets and options the player actually attaches in a real custom-element bootstrap.

## Stack

Step `P1-2` of the player perf proposal. Sits between `P1-1` (shared adopted stylesheets) and `P1-4` (coalescing parent media-time mirror writes) — together they target the studio multi-player render path. The perf gate scenarios in `P0-1*` will pick up the wins automatically.
2026-04-22 16:46:54 -07:00
Vance Ingalls ed62894d01 perf(player): share PLAYER_STYLES via adoptedStyleSheets (#394)
## Summary

Replace per-instance `<style>` injection in `<hyperframes-player>` with a lazily constructed `CSSStyleSheet` adopted via `shadowRoot.adoptedStyleSheets`. One parsed stylesheet, many adopters — the studio thumbnail grid renders dozens of players concurrently and was paying for N parses of the same CSS.

## Why

Step `P1-1` of the player perf proposal. The previous implementation appended a `<style>` element to every shadow root, which means:

- N shadow roots → N copies of the same CSS string parsed into N independent style sheets.
- Each `<style>` lives in the DOM and contributes to layout/style invalidation work when its shadow root churns.
- The studio's project grid mounts ~30 players on initial load — that's 30 redundant parses of the same ~1 KB stylesheet on the critical path.

`adoptedStyleSheets` flips this: parse once at module load, hand the same `CSSStyleSheet` reference to every shadow root.

## What changed

- New `getSharedPlayerStyleSheet()` in `packages/player/src/styles.ts` — module-scoped and memoized; the sheet is built once per process and returned to every adopter.
- New `applyPlayerStyles(shadow)` is the single integration point. It **appends** (never replaces) the shared sheet so any pre-adopted sheets — host themes, scoped overrides, future caller-side injections — survive intact, and is idempotent so repeated calls don't multiply adoptions.
- SSR-safe via a `typeof CSSStyleSheet` guard. Failures (e.g. `replaceSync` throw, no constructor) are cached as `null` so we don't retry constructor failures forever.
- Defensive fallback path creates a per-instance `<style>` element when `adoptedStyleSheets` is unavailable (older runtimes, hostile environments). Behavior on those paths is unchanged from before.
- `PLAYER_STYLES`, `PLAY_ICON`, and `PAUSE_ICON` exports preserved — no public API change.

## Test plan

- [x] Unit tests in `styles.test.ts` cover sharing across instances, fallback when `CSSStyleSheet` is undefined or `replaceSync` throws, fallback when `adoptedStyleSheets` is unsupported on the shadow root, idempotency, and preservation of pre-existing adopted sheets.
- [x] Integration test in `hyperframes-player.test.ts` confirms two real `<hyperframes-player>` elements adopt the same `CSSStyleSheet` instance and inject zero `<style>` elements.
- [x] Build size delta is negligible (utility code replaces `container.appendChild` calls).

## Stack

Step `P1-1` of the player perf proposal. Followed by `P1-2` (scoping the media `MutationObserver`) and `P1-4` (coalescing parent media-time mirror writes) — all three target the studio multi-player render path.
2026-04-22 15:40:33 -07:00
James RussoandClaude Opus 4.7 e72bcfaed3 fix(player+core): correctly render and pause nested compositions (#359)
* fix(player): inject runtime immediately for nested compositions

Compositions that use `data-composition-src` on child elements require
the HyperFrames runtime to load those scenes — there is no way for the
iframe to render without it. The existing probe loop delayed runtime
injection behind a 5-tick attempts gate so the adapter path could try
to resolve a timeline first.

For nested compositions that race lost: a composition like the
`product-promo` registry example registers an inline pre-runtime GSAP
timeline at `window.__timelines["main"]` (covering only a partial
duration, e.g. 14s of a 20s master) while the iframe document loads.
The probe's adapter check finds that timeline and locks the player into
a "ready" state against it — which short-circuits the attempts gate and
the runtime never gets injected. The iframe ends up blank because the
runtime is what would have loaded the child scenes via
`data-composition-src`.

This change splits the injection decision into a pure helper,
`shouldInjectRuntime(state)`, and treats nested compositions as
"inject immediately, skip the gate." Self-contained GSAP-only
compositions retain the 5-tick grace period so the adapter path keeps
first shot for them.

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

* fix(core): propagate play/pause to all sibling timelines

Pausing or playing the master timeline only called `.pause()` / `.play()`
on `state.capturedTimeline` — the single adapter-selected timeline. In a
nested composition (a master with `data-composition-src` children), each
scene's own timeline is registered as a sibling in `window.__timelines`,
so they would keep advancing after the user clicked pause. The player UI
froze at the paused time while the visual content continued to animate,
eventually finishing all scene-level animations and landing on an empty
end-state.

Wire `window.__timelines` into the runtime player via a new
`getTimelineRegistry` dep, iterate the registry on play/pause, and
forward `timeScale` to siblings when play() starts so a changed
playback-rate applies uniformly.

Covered by 7 new unit tests in player.test.ts, including the identity-
equality check (don't double-invoke the master), playbackRate
propagation, a broken-sibling swallow, and a back-compat case with no
registry supplied.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:18:25 -07:00
Miguel Ángel c49181f1fa fix(player): address #298 review — tighter drift, dynamic proxies, ownership event (#307)
Follow-up to PR #298 addressing @jrusso1020's review. Each item below maps to a point in his comment.

## Significant

### 1\. Drift threshold 150 ms → 50 ms

_mirrorParentMediaTime_ was too loose for lip-synced talking-head content. ITU-R BT.1359 puts A/V perceptibility at ±45 ms; 150 ms sat well inside the "unacceptable" zone. Dropped to 50 ms, extracted as a static constant for clarity.

**Verified live on factory-series-c-video (agent-browser):** steady-state offset under parent ownership sampled five times over 400 ms = `[35.7, 33.5, 31.2, 27.2, 36.9]` ms — below the perceptibility floor. Before this PR the same measurement could drift up to 150 ms before correction.

### 2\. Dynamic sub-composition media proxies

Under parent ownership, a sub-composition that attaches a new `<audio data-start>` mid-playback was correctly silenced in the iframe (sticky `outputMuted`) but had no parent-frame counterpart to play → silent hole in the audio track.

Added a `MutationObserver` on the iframe body watching for `audio[data-start]` / `video[data-start]` additions. New elements are adopted through the same `_adoptIframeMedia` helper the initial scan uses, and if parent ownership is already active the new proxy gets its `currentTime` mirrored and `play()` called immediately (gated on `!this._paused`). Observer disconnects on iframe reload + component disconnect.

### 3\. `bridgeMuted` sticky in `syncRuntimeMedia`

The asymmetry James flagged: `outputMuted` was sticky per-tick, `bridgeMuted` was one-shot via `onSetMuted`. A sub-composition activating after a user mute would briefly play at author volume before the next bridge message.

`syncRuntimeMedia` now accepts `userMuted` and the per-clip loop uses a single combined `shouldMute` gate. One invariant, two inputs.

### 4\. Reset `_audioOwner` on iframe reload

The latch never cleared. On composition switch the player would stay in `parent` ownership against a fresh runtime that hadn't received `set-media-output-muted` and whose autoplay-blocked latch was clean — a brief double-audio window until the next `NotAllowedError` re-promoted (idempotently).

`_onIframeLoad` now resets `_audioOwner = "runtime"`, pauses any parent proxies, and disconnects the old MutationObserver before a fresh one attaches to the new document. If the player had been in `parent` ownership, a corresponding `audioownershipchange` event fires with `reason: "iframe-reload"`.

## Worth addressing

### 5\. Promotion → observable event + reason

Promotion was invisible. Added `CustomEvent("audioownershipchange", { detail: { owner, reason } })` fired on every owner transition. `reason` is either `"autoplay-blocked"` (promote → parent) or `"iframe-reload"` (reset → runtime). Gives host apps an SLO-ready signal for "% of sessions in parent ownership" without exposing internal state.

**Verified live:** dispatching a synthetic `media-autoplay-blocked` in the live studio produced `{ owner: "parent", reason: "autoplay-blocked" }` on the web component exactly once.

### 6\. Parent proxy play() rejection → `playbackerror` event

Previously swallowed silently. Now re-emitted as `CustomEvent("playbackerror", { detail: { source: "parent-proxy", error } })` so embedding apps can recover or fall back.

### 7\. Mobile verification on real hardware

Tested with a tunnel in a real iOS device.

## Test gaps (from review)

- `userMuted` stickiness (mirror of the existing `outputMuted` test).
- **OR invariant** between `outputMuted` and `userMuted` — explicit test that setting one false while the other is true keeps `el.muted === true`.
- **Contract pin:** `syncRuntimeMedia` fires `onAutoplayBlocked` on **every** rejection (no internal dedupe) — so a future refactor can't quietly move the latch and break the caller's posting logic.
- **Caller-side latch pattern:** a 5-rejection simulation with the init.ts-style wrapper posts exactly once.
- **`audioownershipchange`** **dispatch** on promotion + once per transition (no duplicate on idempotent re-promote).
- **Mid-playback promotion:** `_paused = false` at flip time fires `_playParentMedia` immediately.
- **`playbackerror`** **surface** on parent proxy rejection with the right `source` tag.

## Minor

- One-line comment on `_promoteToParentProxy` explaining the `postMessage` async race (the mute lands after ~one message-loop tick; the autoplay gate that triggered promotion keeps the iframe rejecting `play()` during that window, so the double-play bug doesn't reappear).

## What's good (from the review)

Kept as-is — noted for posterity:

- `muted` vs `volume` framing (orthogonal channels).
- Probing reality via `NotAllowedError` instead of `matchMedia('(pointer: coarse)')` / UA sniffing.
- Two orthogonal mute channels.
- Backwards compat (new actions / messages safely ignored by either side).

## Test results

- `packages/core/src/runtime/media.test.ts` — **42 tests pass** (+4 new: `userMuted` sticky, OR invariant, fires-every-rejection, caller-latch dedupe)
- `packages/core/src/runtime/bridge.test.ts` — **15 tests pass**
- `packages/player/src/hyperframes-player.test.ts` — **26 tests pass** (+3 new: `audioownershipchange` dispatch, mid-playback promotion, `playbackerror` surface)
- Typecheck green on `core` + `player`
- `tsup` build green on `core` / `player` / `cli`
- Live factory-series-c-video repro via agent-browser: runtime ownership still zero `volumechange` thrash, zero `PARENT.play()` calls; parent ownership measures 27–37 ms steady-state drift, well inside the 50 ms threshold.

## Test plan

- [x] Unit tests (83 total across touched files)
- [x] Typecheck clean
- [x] Build clean
- [x] Live studio repro on factory-series-c-video: runtime path unchanged, parent path drift tightened
- [x] `audioownershipchange` event fires with correct detail on synthetic autoplay block
- [x] Physical iOS / Android device verification (unchanged since #298)
2026-04-18 00:49:57 +02:00
Miguel Ángel 3256551a5e fix(player): single-owner audio to prevent double voice in preview (#298)
## Summary

Fixes the double-voice issue in studio preview where narration plays twice with a drifting offset (measured 23ms → 80ms over a 28s clip).

## Root cause

Two audio pipelines were playing the same source in parallel:

1. The iframe runtime played `<audio data-start>` elements via `syncRuntimeMedia` — the intended path.
2. `<hyperframes-player>` also created parent-frame `<audio>` copies on iframe load and auto-played them in response to every runtime `state` message.

The existing `_muteIframeMedia` tried to silence the iframe copies via `el.volume = 0`, but `syncRuntimeMedia` re-asserts `el.volume` from `data-volume` every tick, so the mute never held. Studio seeks went through `__player.seek()`, which only updated the iframe timeline; parent copies kept their stale `currentTime` and drift compounded across seeks.

Confirmed via agent-browser instrumentation on `factory-series-c-video`:
- 6 `volumechange` events per play cycle (mute-fight signature)
- Both copies audible at `volume=1`, offset growing 23ms → 80ms
- Every seek widened the drift further

PR #295 (v0.4.2) actually **made it audible** — before that, parent copies 404'd on the wrong URL and played silently. Fixing the URL exposed the latent double-playback.

## Fix

Explicit single-owner audio ownership between `<hyperframes-player>` and the runtime.

- **Default ownership is `runtime`**: iframe drives audible playback; parent proxies stay paused and inert. Matches every desktop / studio code path. No parent `play()`, no `volumechange` thrash.
- **On `NotAllowedError`** from the runtime's `play()` attempt (autoplay-gated iframes), the runtime posts `media-autoplay-blocked` once. The player promotes to `parent` ownership: sends `set-media-output-muted: true` to the runtime, starts parent proxies, mirrors `currentTime` from state messages with a 150ms correction threshold.

Two orthogonal mute channels replace the volume fight:

| Channel | Purpose |
|---|---|
| `set-muted` | User's mute preference (existing, unchanged) |
| `set-media-output-muted` | Internal ownership handoff (new) |

`syncRuntimeMedia` now accepts `outputMuted` and asserts `el.muted = true` per active tick — sticky against sub-composition media that arrives mid-playback. Uses native `muted` (orthogonal to `volume`) so no other code path can clobber it.

## Why this shape

- **Single owner, explicit transition.** No races, no tug-of-war.
- **Probes reality, not device class.** We flip on an actual `NotAllowedError`, not on `matchMedia('(pointer: coarse)')` or user-agent sniffing.
- **Uses `muted` instead of abusing `volume`.** `muted` is orthogonal to `volume`; `syncRuntimeMedia` doesn't write to it; author / user settings stay intact.
- **Parent proxies become a thin mirror.** Under parent ownership, their `currentTime` is slaved to the iframe timeline via state messages — no independent drift.
- **Backwards compatible.** Old runtimes without the new bridge action ignore the message; old players without the new message just get the previous behavior.
- **Capture engine unaffected** — it bypasses both DOM pipelines and muxes audio from source files.

## Files changed

- `packages/core/src/runtime/types.ts` — `set-media-output-muted` action + `media-autoplay-blocked` outbound message types.
- `packages/core/src/runtime/state.ts` — `mediaOutputMuted` + `mediaAutoplayBlockedPosted` fields.
- `packages/core/src/runtime/bridge.ts` — route new action to `onSetMediaOutputMuted`.
- `packages/core/src/runtime/media.ts` — `outputMuted` param asserts `el.muted = true` per tick; `NotAllowedError` detection fires `onAutoplayBlocked`.
- `packages/core/src/runtime/init.ts` — wire new bridge handler; coordinate with `set-muted`; post `media-autoplay-blocked` once per session.
- `packages/player/src/hyperframes-player.ts` — `_audioOwner` state; delete `_muteIframeMedia`; `_promoteToParentProxy`; mirror parent `currentTime`; gate all parent play/pause/seek on ownership.

## Verified end-to-end with agent-browser on `factory-series-c-video`

**Runtime ownership (default — desktop studio):**

| | Before | After |
|---|---|---|
| `PARENT.play()` calls per play cycle | 1 | **0** |
| iframe `volumechange` events | 6 | **0** |
| Audible streams | 2 (drifting) | **1 (iframe)** |

**Parent ownership (simulated autoplay block — direct message):**

| | Value |
|---|---|
| iframe audio | `muted=true`, `volume=1` (untouched) |
| parent audio | `muted=false`, `volume=1`, audible |
| Parent ↔ iframe `currentTime` offset | ~6 ms steady state |
| Offset > 150 ms | corrected by mirror sync |

**Mobile path simulated with iPhone 14 emulation + injected `NotAllowedError` from iframe `<audio>.play()`:**

Event timeline captured via agent-browser instrumentation:

```
t=0.0 ms   IFRAME.play() called                       ← runtime attempts playback
t=0.4 ms   IFRAME.play() REJECTED: NotAllowedError    ← simulated mobile gate
t=0.4 ms   →IFRAME bridge set-media-output-muted=true ← player promotes
t=0.6 ms   PARENT.play() called                       ← parent proxy starts
t=0.8 ms   ←IFRAME msg media-autoplay-blocked         ← runtime signal
t=1.0 ms   PARENT.play() resolved                     ← audible
t=1.3 ms   IFRAME muted=true, volume=1                ← iframe silenced via native muted
```

Steady state at t=4 s under promoted parent ownership:

| Element | currentTime | paused | volume | muted |
|---|---|---|---|---|
| Parent audio | 4.060 s | false | 1.0 | **false** (audible) |
| Iframe audio | 4.068 s | false | 1.0 | **true** (silent) |

**Offset: 8 ms**, single audible stream, orthogonal mute channel respected.

## Test plan

- [x] `bunx vitest run` under `packages/core` — **467 / 467 pass** (incl. 4 new `media.test.ts` + 2 new `bridge.test.ts`)
- [x] `bunx vitest run` under `packages/player` — **23 / 23 pass** (3 rewrites for new contract, 2 new for promotion flow)
- [x] `bun run build` — all packages green
- [x] Fresh preview + browser repro on `factory-series-c-video`:
  - [x] Runtime ownership: single audio stream, no drift
  - [x] Parent ownership promotion via direct `media-autoplay-blocked` message: iframe muted, parent audible
  - [x] iPhone 14 emulation + injected `NotAllowedError`: full promotion chain verified in ~1 s, 8 ms steady-state offset
  - [x] No `volumechange` thrash in either ownership mode
- [x] One round of QA on a physical iOS / Android device before release — exercises real `NotAllowedError` path (expected behavior identical to simulation above)
2026-04-17 04:46:22 +02:00
Miguel Ángel e70687b66c fix(player): resolve iframe media src against iframe baseURI (#295)
## Summary

`_setupParentMedia` scans the iframe for `audio[data-start]` / `video[data-start]` and creates parallel media elements in the host document (so the studio can scrub audio at sub-frame precision without iframe cross-origin restrictions). It was reading the raw `src` attribute string and assigning it directly to the host-document element, which then resolved relative URLs against the **studio root** instead of the **iframe**.

Result: a composition like

\`\`\`html
<audio id="narration" data-start="0" data-duration="53" src="assets/narration.wav"></audio>
\`\`\`

played fine in rendered MP4 output but 404'd silently in the studio preview (parent audio got `src = http://localhost:PORT/assets/narration.wav` instead of `http://localhost:PORT/api/projects/<name>/preview/assets/narration.wav`).

## Fix

Resolve the src against \`iframeEl.ownerDocument.baseURI\` before passing it to \`_createParentMedia\`. Also read the raw \`src\` attribute on \`<source>\` fallbacks so both paths go through the same resolution.

Diff is 2 lines of meaningful change (9 total once you include the comment).

## Reproduction

1. Create a project with a narration at \`assets/narration.wav\`
2. Reference it in \`index.html\` with \`<audio data-start="0" data-duration="53" src="assets/narration.wav">\`
3. \`npx hyperframes preview\` → open, click play
4. Before: silent (parent audio's \`error.code === 4\` / \`MEDIA_ERR_SRC_NOT_SUPPORTED\`)
5. After: narration plays, scrubbing syncs

## Test plan

- [x] Existing 21 player tests pass (`bun run --filter=@hyperframes/player test`)
- [x] oxlint + oxfmt clean on changed file
- [x] Manual: verified in-studio playback of a narration sourced via relative URL
- [x] Reviewer: confirm render pipeline unaffected (render doesn't go through `_setupParentMedia`)

## Notes

No tests added for this path because the existing harness covers only the `audio-src` attribute codepath — `_setupParentMedia` is triggered by an internal probe interval against a live iframe, which the current fixture doesn't build. Happy to add one in a follow-up if reviewers want that coverage before merge.
2026-04-17 00:23:07 +02:00
Miguel Ángel a6ff9e2d9f fix(player): preserve iframe media attributes for runtime sync (#291)
## Summary

- `_setupParentMedia()` (added in #266) was stripping `data-start`, `data-duration`, and `src` from audio/video elements inside the composition iframe
- The runtime's `syncRuntimeMedia` queries `audio[data-start]` to find media clips — removing these attributes made the runtime unable to find, sync, or play audio
- Result: silent audio in studio preview and any context where `__player.play()` is called directly (not through the web component)

## Fix

- Keep all iframe media attributes intact so the runtime can track time position and manage playback
- When parent-frame media `play()` succeeds (mobile use case), mute the iframe copies via `volume = 0` to prevent double audio
- On desktop and in the studio (which calls `__player.play()` directly), the runtime's own media sync handles playback normally

## Test plan

- [x] 21 player unit tests pass
- [x] Verified with John Wu's slideshow project: audio element preserves `data-start`, `data-duration`, `src` after runtime init
- [x] Verified runtime `syncRuntimeMedia` finds and plays audio (currentTime advances in sync with timeline)
- [x] Build passes (lint, format, typecheck)
2026-04-16 06:20:28 +02:00
James RussoandClaude Opus 4.6 b23b0751da fix(player): parent-frame media playback for mobile (#266)
* fix(player): parent-frame media playback for mobile

Mobile browsers block media.play() inside iframes when the user
gesture happened in the parent frame — postMessage doesn't transfer
user activation (per the User Activation v2 spec).

## Problem

The player renders compositions in a sandboxed iframe. When a user
taps play in the parent frame, the player sends a postMessage to the
iframe's runtime, which calls audio.play(). On mobile, this fails
silently because the iframe has no user activation context.

## Solution

The player now extracts ALL timed media elements (audio/video with
data-start) from the iframe's DOM (same-origin access), creates
parent-frame copies, and disables the iframe originals. On play(),
parentMedia.play() runs synchronously in the gesture call stack,
satisfying mobile autoplay policy.

### Generic media handling

- Finds all `audio[data-start], video[data-start]` in the iframe
- Creates a parent-frame copy for each (Audio or Video element)
- Preserves data-start offsets for correct seek positioning
- Strips data-start from iframe elements so the runtime ignores them
- Falls back to iframe media for cross-origin iframes

### `audio-src` attribute

Convenience for the common single-narration case. When set, the
player starts preloading audio immediately — before the iframe loads.
This eliminates the loading delay that caused jittery playback.

### No active sync

Both parent media and the GSAP timeline are real-time systems. When
started simultaneously, they naturally stay within ~10ms — no drift
correction needed. Active sync with coarse granularity (50ms polling)
caused MORE jitter than it prevented via repeated audio seeks.

## CI

- Added unified `test` job replacing separate per-package test jobs
- Added root `test` script: `bun run --filter '*' test`
- New packages with test scripts are automatically included
- Added happy-dom for player DOM tests

## Tests

- 10 new tests for parent-frame media: preloading, play, pause,
  seek, muted/rate sync, cleanup, attribute changes
- All 21 player tests pass

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

* fix(shader-transitions): pass CI when no test files exist

Add --passWithNoTests to vitest run so the unified test job
doesn't fail on packages that have a test script but no test
files yet.

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

* fix(cli): update tests for new id field and GSAP lint rule

- normalize.test.ts: loadTranscript now assigns id fields (w0, w1, etc.)
  to SRT/VTT results and empty string for words-json passthrough
- lintProject.test.ts: add GSAP CDN script to validHtml() fixture to
  satisfy the missing_gsap_script lint rule added in core

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

* fix(cli): add missing data-start/data-duration to validHtml fixture

The validHtml() test fixture was missing data-start and data-duration
attributes, triggering the root_composition_missing_data_start and
root_composition_missing_data_duration lint warnings.

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

* fix(ci): fetch LFS objects for producer test job

Producer regression tests compare rendered output against reference MP4
files stored in git LFS. Without lfs: true, checkout fetches pointer
files instead of actual videos, causing "moov atom not found" errors.

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

* ci: remove redundant test-producer job

The regression workflow already runs the same 28 producer fixtures
in a Docker container with prod-matching Chrome/fonts/ffmpeg, sharded
across 8 parallel matrix jobs with 40-min timeouts. The CI test-producer
job was a duplicate that ran on bare runners with worse determinism
and a 15-min timeout too short for all fixtures.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:20:07 -07:00
Miguel Ángel f40447f2e8 fix(player,studio): resolve root timeline from DOM instead of last key (#247)
Bundled previews register a master composition alongside its sub-compositions
in `window.__timelines`, e.g. { main, intro, scene2, scene5 }. Both the
player's probe and studio's getAdapter() were using `keys[keys.length - 1]`
to pick the adapter, which returned whichever timeline was registered last.

That made the player report the final sub-composition's duration as the
video length (e.g. 3.2s instead of the master's 14s) and play/pause/seek
targeted that sub-composition instead of the full composition.

Look up the outermost `[data-composition-id]` element in the iframe DOM
and use its id to select the right timeline. Falls back to last-key when
no element is present (standalone sub-composition previews) so drill-down
views keep working.

Also restores `main`/`import` entry points on @hyperframes/player to
point at compiled dist output (the src/ paths broke workspace consumers
that only receive the published tarball).
2026-04-13 23:15:08 +02:00
Miguel Ángel 1149602bc9 fix(studio): support web-component refs in useTimelinePlayer (#245)
* fix(studio): support web-component refs in useTimelinePlayer

The studio's `useTimelinePlayer` hook returns an `iframeRef` that
consumers attach to an `<iframe>` element. When consumers wrap the
iframe in a custom element (e.g. `<hyperframes-player>`) that puts
the iframe inside its shadow DOM, every `iframeRef.current.contentWindow`
access returned `null` and `getAdapter()` silently failed — meaning
timeline seek, play, pause, and `refreshPlayer` all became no-ops.

Changes:
- Add `resolveIframe(el)` helper that returns the underlying iframe
  whether the host is the iframe itself, a custom element with a
  shadow-DOM iframe, or a wrapper with a descendant iframe.
- Export `resolveIframe` from the studio so consumers can pre-resolve
  the iframe before assigning it to `iframeRef`.
- Internal `useTimelinePlayer` keeps the strict `HTMLIFrameElement`
  ref type, so existing consumers attaching directly to an `<iframe>`
  are unaffected.

Also adds:
- JSDoc on the player's `iframeElement` getter.
- "Advanced: iframe access" docs section in `packages/player/README.md`
  and `docs/packages/player.mdx`.
- Type-safety lint rules in `.oxlintrc.json` and a "Type-safety
  conventions" section in `CONTRIBUTING.md`.

Backward compatible — App.tsx and NLELayout.tsx continue to work
unchanged.

* chore(lint): defer no-explicit-any rule; it broke existing codebase

The new rules added 37 errors across 32 existing files — mostly
legitimate `window as any` casts at browser-global and test-mock
boundaries. Enabling them without fixing all violations breaks CI.

Revert the `.oxlintrc.json` additions and soften the CONTRIBUTING.md
wording to describe the convention without claiming lint enforcement
(that enforcement will come in a follow-up PR that fixes all sites).
2026-04-13 17:53:01 +02:00
James RussoandClaude Opus 4.6 18de86e4bd fix(player): handle Infinity duration; add lint rules for data-duration and Math.ceil overshoot (#243)
* fix(player): handle Infinity duration from runtime gracefully

When compositions have repeating animations without data-duration, the
runtime sends durationInFrames: Infinity. The player now ignores
non-finite duration values instead of displaying "Infinity:NaN" in the
controls. formatTime also returns "0:00" for non-finite inputs.

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

* feat(lint): add data-duration and Math.ceil overshoot rules

- Add root_composition_missing_data_duration warning when the root
  composition element is missing data-duration, which causes the runtime
  to infer Infinity for loop-inflated timelines.
- Add gsap_repeat_ceil_overshoot warning that catches
  repeat: Math.ceil(d/c)-1 patterns which overshoot the intended
  duration. Recommends Math.floor instead.
- Fix gsap_infinite_repeat fixHint to suggest Math.floor (not Math.ceil).

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

* fix(player): wait for injected runtime before declaring ready

When the player auto-injects the runtime script (because the
composition has GSAP timelines but no runtime), it would immediately
declare ready on the next probe cycle — before the runtime script
finished loading from CDN. This caused play() to send a postMessage
that nobody received, making autoplay silently fail.

Now the probe waits for the runtime bridge (__hf or __player) to
appear before proceeding to the ready state.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:41:13 -07:00
Miguel Ángel 0da93cea3d feat(player): add speed control with popup menu and CSS theming (#241)
Add playback speed control to the player controls bar:
- Popup menu with logarithmic presets (0.25x-4x)
- Custom presets via speed-presets attribute
- Full CSS custom property theming (--hfp-accent, --hfp-controls-bg, etc.)
- ratechange event dispatch
- Exports: SPEED_PRESETS, formatSpeed, ControlsOptions
- Fix package.json export condition ordering
2026-04-10 20:47:12 +02:00
Miguel Ángel 3482441c9f feat(studio): use @hyperframes/player web component for preview (#238)
## Summary

- **Replaces the studio's hand-rolled iframe + scaling in** **`Player.tsx`** with the `<hyperframes-player>` web component, eliminating duplicated ResizeObserver, dimension detection, and stage-size message handling
- **Adds a public** **`iframeElement`** **getter** to the player web component so the studio's `useTimelinePlayer` can still access the inner iframe for clip manifest parsing, timeline probing, and DOM inspection
- **Updates player package exports** to resolve from source for workspace consumers (matching `@hyperframes/core` pattern), while npm-published consumers still get built `dist/` files

### Why a separate player package?

1. **Zero dependencies, any framework** — 12KB vanilla web component vs 940KB React+Zustand+CodeMirror studio
2. **CDN-ready** — single `<script>` tag, no build pipeline needed
3. **Embeddable by third parties** — users embed compositions in their own sites without the studio
4. **Single source of truth** — studio now uses the player instead of duplicating its scaling/detection logic

## Test plan

- [x] `pnpm --filter @hyperframes/player typecheck` passes
- [x] `pnpm --filter @hyperframes/studio typecheck` passes
- [x] `pnpm --filter @hyperframes/studio build` passes
- [x] `pnpm --filter @hyperframes/studio test` passes (2 pre-existing failures, unrelated)
- [x] E2E: Standalone player loads composition, detects 4s GSAP timeline, controls work, play/pause works
- [x] E2E: Studio preview renders via `<hyperframes-player>`, `iframeElement` bridge works, playback controls sync correctly
2026-04-10 03:00:54 +02:00
Miguel Ángel 5655dabff6 feat: allow clip animation + ship <hyperframes-player> web component (#209)
## Summary

Two independent initiatives that improve agent DX and expand HyperFrames' reach.

### Initiative 1: Fix the Clip Animation Footgun

- `gsap_animates_clip_element` lint rule now uses smart detection — only errors when GSAP animates `visibility` or `display` on a clip element
- All other properties (opacity, transform, x, y, scale, etc.) are allowed silently
- This was the #1 agent failure in QA (10/10 agents hit it on v0.2.1)

### Initiative 2: `<hyperframes-player>` Web Component

- New `@hyperframes/player` package — zero dependencies, 3.3KB gzipped
- Iframe-based web component with Shadow DOM for perfect isolation
- Video-like API: `play()`, `pause()`, `seek()`, `currentTime`, `duration`, events
- Controls overlay with play/pause, scrubber (mouse + touch), time display, auto-hide
- Full docs page at `docs/packages/player.mdx`

## Before / After

### Clip animation lint

**Before (10/10 agents hit this):**

```
✗ gsap_animates_clip_element: GSAP animation targets a clip element.
  Selector "#title" resolves to element <div id="title" class="clip">.
  The framework manages clip visibility — animate an inner wrapper instead.
  Fix: Wrap content in a child <div> and target that with GSAP.
```

**After (only errors on actual conflicts):**

```
# This passes lint — no error:
tl.from("#title", { opacity: 0, y: -50, scale: 0.8 }, 0);

# This still errors — actual conflict with runtime:
tl.to("#title", { visibility: "hidden" }, 3);
✗ gsap_animates_clip_element: GSAP animation sets visibility on a clip element.
  Fix: Remove the visibility/display tween. Use opacity for fade effects.
```

### Embeddable player

**Before:** No way to embed a composition in a web page.
**After:**

```html
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player"></script>
<hyperframes-player src="./composition/index.html" controls></hyperframes-player>
```

```js
const player = document.querySelector('hyperframes-player');
player.play();
player.pause();
player.seek(2.5);
player.addEventListener('ready', (e) => console.log('Duration:', e.detail.duration));
```

## Test plan

- [x] 427 core tests pass (20 GSAP lint tests with smart detection)
- [x] 7 player tests pass (formatTime + element registration)
- [x] TypeScript compiles cleanly (core + player)
- [x] Lint: GSAP animating clip with safe props → 0 errors
- [x] Lint: GSAP animating clip with `visibility` → 1 error (correct)
- [x] Player builds to 3.3KB gzipped ESM
- [x] Lockfile updated for CI
- [x] Docs page added at `docs/packages/player.mdx`
2026-04-06 19:59:39 +02:00